[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nend_of_line = lf\n\n[*.{py,ts,tsx}]\nindent_style = space\ninsert_final_newline = true\n\n[*.py]\nindent_size = 4\ntrim_trailing_whitespace = true\n\n[*.{ts,tsx}]\nindent_size = 2\n"
  },
  {
    "path": ".eslintignore",
    "content": "node_modules\ndist"
  },
  {
    "path": ".eslintrc",
    "content": "{\n  \"root\": true,\n  \"parser\": \"@typescript-eslint/parser\",\n  \"ignorePatterns\": [\"**/*.jsx\"],\n  \"plugins\": [\"@typescript-eslint\"],\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/eslint-recommended\",\n    \"plugin:@typescript-eslint/recommended\"\n  ],\n  \"rules\": {\n    \"@typescript-eslint/no-non-null-assertion\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"no-unused-vars\": \"off\",\n    \"@typescript-eslint/no-unused-vars\": [\n      \"error\",\n      {\n        \"argsIgnorePattern\": \"^_\",\n        \"varsIgnorePattern\": \"^_\",\n        \"caughtErrorsIgnorePattern\": \"^_\",\n        \"ignoreRestSiblings\": true\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @hayescode @asvishnyakov @sandangel"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: needs-triage\nassignees: ''\ntype: 'Bug'\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n\n- OS: [e.g. iOS]\n- Browser [e.g. chrome, safari]\n- Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n\n- Device: [e.g. iPhone6]\n- OS: [e.g. iOS8.1]\n- Browser [e.g. stock browser, safari]\n- Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: needs-triage\nassignees: ''\ntype: 'Feature'\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/actions/pnpm-node-install/action.yaml",
    "content": "name: Install Node, pnpm and dependencies.\ndescription: Install Node, pnpm and dependencies using cache.\n\ninputs:\n  node-version:\n    description: Node.js version\n    required: true\n    default: '24.3.0' # Switch to 'lts' as soon as Node 24 reaches LTS status.\n\nruns:\n  using: composite\n  steps:\n    - uses: pnpm/action-setup@v4\n      name: Install pnpm\n      with:\n        run_install: false\n    - name: Use Node.js\n      uses: actions/setup-node@v4\n      with:\n        node-version: ${{ inputs.node-version }}\n        registry-url: 'https://registry.npmjs.org'\n        cache: 'pnpm'\n        cache-dependency-path: '**/pnpm-lock.yaml'\n    - name: Install JS dependencies\n      run: pnpm install\n      shell: bash"
  },
  {
    "path": ".github/actions/uv-python-install/action.yaml",
    "content": "name: Install Python, uv and dependencies.\ndescription: Install Python, uv and project dependencies using cache\n\ninputs:\n  python-version:\n    description: Python version\n    required: true\n    default: '3.10'\n  uv-version:\n    description: uv version\n    required: true\n    default: 'latest'\n  working-directory:\n    description: Working directory for uv command.\n    required: false\n    default: .\n  extra-dependencies:\n    description: Extra dependencies to install, e.g. --extra tests --extra dev.\n    required: false\n\nruns:\n  using: composite\n  steps:\n    - name: Install uv\n      uses: astral-sh/setup-uv@v4\n      with:\n        version: ${{ inputs.uv-version }}\n        enable-cache: true\n    - name: Set up Python ${{ inputs.python-version }}\n      id: setup_python\n      uses: actions/setup-python@v5\n      with:\n        python-version: ${{ inputs.python-version }}\n    - name: Install Python dependencies\n      run: uv sync --no-install-project --no-editable ${{ inputs.extra-dependencies }}\n      shell: bash\n      working-directory: ${{ inputs.working-directory }}\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "# Chainlit Development Instructions\n\nChainlit 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.\n\nAlways reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.\n\n## Working Effectively\n\n### Bootstrap, Build, and Test the Repository\n\n**CRITICAL**: All commands must complete - NEVER CANCEL any build or test operations. Use appropriate timeouts.\n\n1. **Install Dependencies (Required first)**:\n   ```bash\n   # Install uv (if not available)\n   python3 -m pip install pipx\n   python3 -m pipx install uv\n   export PATH=\"$HOME/.local/bin:$PATH\"\n   \n   # Install pnpm (if not available)  \n   npm install -g pnpm\n   \n   # Install Python dependencies - takes ~2 minutes, NEVER CANCEL\n   cd backend\n   uv sync --extra tests --extra mypy --extra dev --extra custom-data\n   # Timeout: Use 300+ seconds (5+ minutes)\n   \n   # Install Node.js dependencies - takes ~3 minutes, NEVER CANCEL  \n   cd ..\n   pnpm install --frozen-lockfile\n   # Timeout: Use 600+ seconds (10+ minutes)\n   # NOTE: Cypress download may fail due to network restrictions - this is expected in CI environments\n   ```\n\n2. **Build the Frontend - takes ~1 minute, NEVER CANCEL**:\n   ```bash\n   pnpm run buildUi\n   # Timeout: Use 300+ seconds (5+ minutes)\n   ```\n\n3. **Run Tests**:\n   ```bash\n   # Backend tests - takes ~17 seconds, NEVER CANCEL\n   cd backend\n   export PATH=\"$HOME/.local/bin:$PATH\"\n   uv run pytest --cov=chainlit/\n   # Timeout: Use 120+ seconds (2+ minutes)\n   \n   # Frontend tests - takes ~4 seconds\n   cd ../frontend  \n   pnpm test\n   # Timeout: Use 60 seconds\n   \n   # E2E tests require Cypress download - may not work in restricted environments\n   # If available: pnpm test (takes variable time depending on tests)\n   ```\n\n4. **Run Development Servers**:\n   ```bash\n   # Start backend (in one terminal)\n   cd backend\n   export PATH=\"$HOME/.local/bin:$PATH\" \n   uv run chainlit run chainlit/sample/hello.py -h\n   # Available at http://localhost:8000\n   \n   # Start frontend dev server (in another terminal)\n   cd frontend\n   pnpm run dev  \n   # Available at http://localhost:5173/\n   ```\n\n## Validation\n\n### Manual Validation Requirements\n- **ALWAYS** manually validate any changes by running complete scenarios.\n- **ALWAYS** test the Chainlit application after making changes.\n- Create a test app and verify it runs: `uv run chainlit run /path/to/test.py -h`\n- **ALWAYS** run through at least one complete user workflow after making changes.\n\n### Linting and Formatting - takes ~2 minutes, NEVER CANCEL\n```bash\n# Run all linting (UI + Python) \npnpm run lint\n# Timeout: Use 300+ seconds (5+ minutes)\n\n# Format UI code - takes ~5 seconds\npnpm run formatUi\n\n# Format Python code using ruff (preferred)\ncd backend\nexport PATH=\"$HOME/.local/bin:$PATH\"\nuv run ruff format chainlit/ tests/\n\n# NOTE: pnpm run formatPython may fail if black is not installed\n# Use ruff format instead as shown above\n```\n\n### CI Requirements\n- **ALWAYS** run `pnpm run lint` before committing or the CI (.github/workflows/ci.yaml) will fail.\n- The CI runs: pytest, lint-backend, lint-ui, and e2e-tests.\n- **NEVER CANCEL** any CI commands - they take time but must complete.\n\n## Key Project Structure\n\n### Repository Root\n```\n/\n├── README.md\n├── CONTRIBUTING.md  \n├── package.json              # Root pnpm workspace config\n├── pnpm-workspace.yaml       # Workspace definition\n├── backend/                  # Python backend with uv\n├── frontend/                 # React frontend app\n├── libs/\n│   ├── react-client/         # React client library\n│   └── copilot/             # Copilot functionality\n├── cypress/                  # E2E tests\n└── .github/\n    ├── workflows/            # CI/CD pipelines\n    └── actions/              # Reusable GitHub actions\n```\n\n### Working with the Backend\n- **Technology**: Python 3.10+ with uv, FastAPI, SocketIO\n- **Entry point**: `backend/chainlit/` \n- **Tests**: `backend/tests/`\n- **Dependencies**: Defined in `backend/pyproject.toml`\n- **Hello app**: `backend/chainlit/sample/hello.py`\n\n### Working with the Frontend  \n- **Technology**: React 18+ with Vite, TypeScript, Tailwind CSS\n- **Entry point**: `frontend/src/`\n- **Dependencies**: Defined in `frontend/package.json`\n- **Build output**: `frontend/dist/`\n\n## Common Tasks\n\n### Creating a New Chainlit App\n```python\n# Create app.py\nimport chainlit as cl\n\n@cl.on_message\nasync def main(message: cl.Message):\n    await cl.Message(content=f\"You said: {message.content}\").send()\n\n# Run it\nuv run chainlit run app.py -w\n```\n\n### Timing Expectations\n- **pnpm install**: ~3 minutes (may fail on Cypress - this is normal)\n- **uv install**: ~2 minutes  \n- **pnpm run buildUi**: ~1 minute\n- **pnpm run lint**: ~2 minutes\n- **Backend tests**: ~17 seconds\n- **Frontend tests**: ~4 seconds\n- **pnpm run formatUi**: ~5 seconds\n\n### Common Gotchas\n- **NEVER CANCEL** long-running operations - they need time to complete.\n- Cypress download often fails in CI environments - this is expected.\n- Use `uv run` prefix for all Python commands in backend.\n- Use `export PATH=\"$HOME/.local/bin:$PATH\"` to ensure uv is available.\n- The `pnpm run formatPython` command may fail - use `uv run ruff format` instead.\n- Frontend dev server connects to backend at localhost:8000.\n- Always start backend before frontend for development.\n\n### File Locations for Quick Reference\n- **Main CLI**: `backend/chainlit/cli/`\n- **Server code**: `backend/chainlit/server.py`\n- **Frontend app**: `frontend/src/App.tsx`\n- **React client**: `libs/react-client/src/`\n- **CI workflows**: `.github/workflows/ci.yaml`\n- **uv config**: `backend/pyproject.toml`\n- **Frontend config**: `frontend/package.json`\n\n## Requirements\n- **Python**: >= 3.10\n- **Node.js**: >= 20 (24+ recommended)\n- **uv**: 2.1.3 (install via pipx)\n- **pnpm**: Latest (install via npm)"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non:\n  workflow_call:\n  workflow_dispatch:\n  merge_group:\n  pull_request:\n    branches: [main, dev, 'release/**']\n    paths-ignore:\n      - '*.md'\n      - LICENSE\n  push:\n    branches: [main, dev, 'release/**']\n    paths-ignore:\n      - '*.md'\n      - LICENSE\n\npermissions: read-all\n\njobs:\n  pytest:\n    uses: ./.github/workflows/pytest.yaml\n    secrets: inherit\n  lint-backend:\n    uses: ./.github/workflows/lint-backend.yaml\n    secrets: inherit\n  e2e-tests:\n    uses: ./.github/workflows/e2e-tests.yaml\n    secrets: inherit\n  lint-ui:\n    uses: ./.github/workflows/lint-ui.yaml\n    secrets: inherit\n  ci:\n    runs-on: ubuntu-latest\n    name: Run CI\n    if: always()  # This ensures the job always runs\n    needs: [lint-backend, pytest, lint-ui, e2e-tests]\n    steps:\n      # Propagate failure\n      - name: Check dependent jobs\n        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'action_required') || contains(needs.*.result, 'timed_out')\n        run: |\n          echo \"Not all required jobs succeeded\"\n          exit 1\n"
  },
  {
    "path": ".github/workflows/close_stale.yml",
    "content": "name: Close inactive issues and pull requests\non:\n  schedule:\n    - cron: \"30 1 * * *\"\n  workflow_dispatch:\n  \njobs:\n  close-issues:\n    runs-on: ubuntu-latest\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - uses: actions/stale@v9\n        with:\n          operations-per-run: 400\n          ascending: true\n          days-before-issue-stale: 14\n          days-before-issue-close: 7\n          stale-issue-label: \"stale\"\n          exempt-issue-labels: \"enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while\"\n          stale-issue-message: \"This issue is stale because it has been open for 14 days with no activity.\"\n          close-issue-message: \"This issue was closed because it has been inactive for 7 days since being marked as stale.\"\n          days-before-pr-stale: 14\n          days-before-pr-close: 7\n          stale-pr-label: \"stale\"\n          exempt-pr-labels: \"enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while\"\n          stale-pr-message: \"This PR is stale because it has been open for 14 days with no activity.\"\n          close-pr-message: \"This PR was closed because it has been inactive for 7 days since being marked as stale.\"\n          repo-token: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".github/workflows/copilot-setup-steps.yaml",
    "content": "name: \"Copilot Setup Steps\"\n\n# Automatically run the setup steps when they are changed to allow for easy validation, and\n# allow manual testing through the repository's \"Actions\" tab\n# This workflow optimizes the GitHub Copilot coding agent's ephemeral development environment\non:\n  workflow_dispatch:\n  push:\n    paths:\n      - .github/workflows/copilot-setup-steps.yaml\n  pull_request:\n    paths:\n      - .github/workflows/copilot-setup-steps.yaml\n\njobs:\n  # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.\n  copilot-setup-steps:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n\n    # Set the permissions to the lowest permissions possible needed for your steps.\n    # Copilot will be given its own token for its operations.\n    permissions:\n      # 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.\n      contents: read\n\n    # You can define any steps you want, and they will run before the agent starts.\n    # If you do not check out your code, Copilot will do this for you.\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Install Node.js, pnpm and dependencies\n        uses: ./.github/actions/pnpm-node-install\n\n      - name: Install Python, uv and dependencies\n        uses: ./.github/actions/uv-python-install\n        with:\n          python-version: \"3.10\"\n          uv-version: \"latest\"\n          working-directory: \"./backend\"\n          extra-dependencies: \"--extra tests --extra mypy --extra dev --extra custom-data\"\n\n      - name: Build UI components\n        run: pnpm run buildUi\n        timeout-minutes: 5"
  },
  {
    "path": ".github/workflows/e2e-tests.yaml",
    "content": "name: E2ETests\n\non: [workflow_call]\n\npermissions: read-all\n\njobs:\n  ci:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    env:\n      BACKEND_DIR: ./backend\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/pnpm-node-install\n        name: Install Node, pnpm and dependencies.\n      - name: Install Cypress\n        uses: cypress-io/github-action@v6\n        with:\n          runTests: false\n      - uses: ./.github/actions/uv-python-install\n        name: Install Python, uv and Python & pnpm (uv does it automatically) dependencies\n        with:\n          working-directory: ${{ env.BACKEND_DIR }}\n          extra-dependencies: --extra tests\n      - name: Build UI components\n        run: pnpm run buildUi\n        timeout-minutes: 5\n      - name: Run tests\n        env:\n          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}\n        run: pnpm test\n        shell: bash\n      - name: Upload screenshots\n        uses: actions/upload-artifact@v4\n        if: always() && hashFiles('cypress/screenshots/**') != ''\n        with:\n          name: cypress-screenshots-${{ matrix.os }}\n          path: cypress/screenshots\n"
  },
  {
    "path": ".github/workflows/lint-backend.yaml",
    "content": "name: LintBackend\n\non: [workflow_call]\n\npermissions: read-all\n\njobs:\n  lint-backend:\n    runs-on: ubuntu-latest\n    env:\n      BACKEND_DIR: ./backend\n    steps:\n      - uses: actions/checkout@v6\n      - uses: ./.github/actions/uv-python-install\n        name: Install Python, uv and Python dependencies\n        with:\n          extra-dependencies: --extra tests --extra mypy --extra custom-data\n          working-directory: ${{ env.BACKEND_DIR }}\n      - name: Lint with ruff\n        uses: astral-sh/ruff-action@v1\n        with:\n          src: ${{ env.BACKEND_DIR }}\n          changed-files: \"true\"\n      - name: Check formatting with ruff\n        uses: astral-sh/ruff-action@v1\n        with:\n          src: ${{ env.BACKEND_DIR }}\n          changed-files: \"true\"\n          args: \"format --check\"\n      - name: Run Mypy\n        run: uv run --no-project mypy chainlit/ tests/\n        working-directory: ${{ env.BACKEND_DIR }}\n"
  },
  {
    "path": ".github/workflows/lint-ui.yaml",
    "content": "name: LintUI\n\non: [workflow_call]\n\npermissions: read-all\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/pnpm-node-install\n        name: Install Node, pnpm and dependencies.\n      - name: Build UI\n        run: pnpm run buildUi\n      - name: Lint UI\n        run: pnpm run lintUi\n"
  },
  {
    "path": ".github/workflows/publish-libs.yaml",
    "content": "name: Publish libs\n\non:\n  workflow_dispatch:\n    inputs:\n      dry_run:\n        description: 'Dry run (test publishing)'\n        required: false\n        default: false\n        type: boolean\n  release:\n    types: [published]\n\npermissions: read-all\n\njobs:\n  validate:\n    name: Validate inputs\n    runs-on: ubuntu-latest\n    steps:\n      - name: Validate publishing branch and destination package index\n        run: |\n          if [[ \"${{ github.ref_name }}\" != \"main\" && \"${{ github.event_name }}\" != \"release\" ]]; then\n            if [[ \"${{ inputs.dry_run }}\" != \"true\" ]]; then\n              echo \"❌ Error: Only build from main branch or release tag can be published to npm registry.\"\n              echo \"Please check 'Dry run (test publishing)' when running from branch: ${{ github.ref_name }}\"\n              exit 1\n            fi\n          fi\n          echo \"✅ Validation passed\"\n  ci:\n    needs: [validate]\n    uses: ./.github/workflows/ci.yaml\n    secrets: inherit\n  build-n-publish:\n    name: Upload libs release to npm registry\n    runs-on: ubuntu-latest\n    needs: [ci]\n    permissions:\n      contents: read\n      id-token: write # IMPORTANT: this permission is mandatory for trusted publishing\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/pnpm-node-install\n        name: Install Node, pnpm and dependencies.\n\n      - name: Build libs\n        run: pnpm build:libs\n\n      - name: Publish packages to npm\n        # --no-git-checks allows testing from non-main branches and publishing from release tags\n        run: pnpm publish --recursive --no-git-checks ${{ inputs.dry_run && '--dry-run' || '' }}\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_REACT_CLIENT }}"
  },
  {
    "path": ".github/workflows/publish.yaml",
    "content": "name: Publish\n\non:\n  workflow_dispatch:\n    inputs:\n      use_testpypi:\n        description: 'Publish to TestPyPI instead of PyPI'\n        required: false\n        default: false\n        type: boolean\n  release:\n    types: [published]\n\npermissions: read-all\n\njobs:\n  validate:\n    name: Validate inputs\n    runs-on: ubuntu-latest\n    steps:\n      - name: Validate publishing branch and destination package index\n        run: |\n          if [[ \"${{ github.ref_name }}\" != \"main\" && \"${{ github.event_name }}\" != \"release\" ]]; then\n            if [[ \"${{ inputs.use_testpypi }}\" != \"true\" ]]; then\n              echo \"❌ Error: Only build from main branch or release tag can be published to PyPI.\"\n              echo \"Please check 'Publish to TestPyPI instead of PyPI' when running from branch: ${{ github.ref_name }}\"\n              exit 1\n            fi\n          fi\n          echo \"✅ Validation passed\"\n  ci:\n    needs: [validate]\n    uses: ./.github/workflows/ci.yaml\n    secrets: inherit\n  build-n-publish:\n    name: Upload release to PyPI/TestPyPI\n    runs-on: ubuntu-latest\n    needs: [ci]\n    env:\n      name: ${{ inputs.use_testpypi && 'testpypi' || 'pypi' }}\n      url: ${{ inputs.use_testpypi && 'https://test.pypi.org/project/chainlit' || 'https://pypi.org/project/chainlit' }}\n      BACKEND_DIR: ./backend\n    permissions:\n      contents: read\n      id-token: write # IMPORTANT: this permission is mandatory for trusted publishing\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/pnpm-node-install\n        name: Install Node, pnpm and dependencies.\n      - uses: ./.github/actions/uv-python-install\n        name: Install Python, uv and Python dependencies\n        with:\n          working-directory: ${{ env.BACKEND_DIR }}\n\n      - name: Build Python distribution\n        run: uv build\n        working-directory: ${{ env.BACKEND_DIR }}\n\n      - name: Check frontend and copilot folder included\n        run: |\n          pip install wheel\n          python -m wheel unpack dist/chainlit-*.whl -d unpacked\n          ls unpacked/chainlit-*/chainlit/frontend/dist\n          ls unpacked/chainlit-*/chainlit/copilot/dist\n        working-directory: ${{ env.BACKEND_DIR }}\n      \n      - name: Publish package distributions to TestPyPI\n        if: inputs.use_testpypi\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          packages-dir: backend/dist\n          repository-url: https://test.pypi.org/legacy/\n          password: ${{ secrets.TEST_PYPI_API_TOKEN }}\n          verbose: true\n      \n      - name: Publish package distributions to PyPI\n        if: ${{ !inputs.use_testpypi }}\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          packages-dir: backend/dist\n          password: ${{ secrets.PYPI_API_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/pytest.yaml",
    "content": "name: Pytest\n\non: [workflow_call]\n\npermissions: read-all\n\njobs:\n  pytest:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: ['3.10', '3.11', '3.12', '3.13']\n    env:\n      BACKEND_DIR: ./backend\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/pnpm-node-install\n        name: Install Node, pnpm and dependencies.\n      - uses: ./.github/actions/uv-python-install\n        name: Install Python, uv and Python dependencies\n        with:\n          python-version: ${{ matrix.python-version }}\n          extra-dependencies: --extra tests --extra mypy --extra custom-data\n          working-directory: ${{ env.BACKEND_DIR }}\n      - name: Build UI components\n        run: pnpm run buildUi\n        timeout-minutes: 5\n      - name: Run Pytest\n        run: uv run --no-project pytest --cov=chainlit/\n        working-directory: ${{ env.BACKEND_DIR }}\n"
  },
  {
    "path": ".gitignore",
    "content": "build\ndist\n\n*.egg-info\n\n.env\n\n*.files\n\nvenv\n.venv\n.DS_Store\n\n**/.chainlit/*\nchainlit.md\n\ncypress/screenshots\ncypress/videos\ncypress/downloads\n\n__pycache__\n\n.ipynb_checkpoints\n\n*.db\n\n.mypy_cache\n\nchat_files\n\n.chroma\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.pnpm-store\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n.aider*\n.coverage\n\nbackend/README.md\nbackend/.dmypy.json\n\n.history\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm lint-staged\n"
  },
  {
    "path": ".npmrc",
    "content": "shared-workspace-lockfile=false\npublic-hoist-pattern[]=*eslint*\npublic-hoist-pattern[]=*prettier*\npublic-hoist-pattern[]=@types*\nside-effects-cache=false\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": true,\n  \"trailingComma\": \"none\",\n  \"singleQuote\": true,\n  \"printWidth\": 80,\n  \"plugins\": [\"@trivago/prettier-plugin-sort-imports\"],\n  \"importOrder\": [\n    \"pages/(.*)$\",\n    \"@chainlit/(.*)$\",\n    \"components/(.*)$\",\n    \"assets/(.*)$\",\n    \"hooks/(.*)$\",\n    \"state/(.*)$\",\n    \"types/(.*)$\",\n    \"^./*.*.css\",\n    \"^[./]\"\n  ],\n  \"importOrderSeparation\": true,\n  \"importOrderSortSpecifiers\": true\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to Chainlit will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n## [2.10.0] - 2026-03-05\n\n### Added\n- Add starter categories for grouped starters\n- Always show the favorite messages button with an empty state\n- Add option to disable rendering markdown in user messages\n- Allow easy deletion of favorites\n- Make state cookie lifetime configurable via env var\n- Add Arabic translation\n- Add Danish translation\n- Add settings change listener\n- Add image preview\n- Add selected option for command pre-selection\n- Add `auto_collapse` parameter to `Step`\n- Add `/health` endpoint for container orchestration\n- Add `hidden` option for `default_sidebar_state`\n- Make avatar size configurable via `config.toml`\n\n### Fixed\n- Reorder chat history sidebar after messages in existing chats\n- Use login error detail for credential failures\n- Convert UUID fields to strings in feedback extraction\n- Preserve thread metadata when updated without metadata\n- Reset audio UI when microphone permission is denied\n- Fix sidebar inset overflow causing horizontal scroll\n- Prevent empty strings from overwriting step content on upsert\n- Use correct URL scheme when SSL is configured\n\n## [2.9.6] - 2026-01-20\n\n### Added\n- Allow skip new chat creation\n- Add data picker input widget\n- Toggle chat settings in sidebar instead of composer\n\n### Fixed\n- Fix: Starters now correctly use the selected/default mode if configured\n\n## [2.9.5] - 2026-01-08\n\n### Added\n- Add favorite messages (prompt templates)\n\n### Fixed\n- Fix: Starters now correctly use the selected/default mode if configured\n\n## [2.9.4] - 2025-12-24\n\n### Added\n- Add an icon for shared thread\n- New option to allow disabling auto scroll of assistant messages\n- Add modes: you may allow users to select an LLM model, a mode (for example, planning), allow to enable reasoning etc.\n  - Breaking change: you need to run `ALTER TABLE steps ADD COLUMN IF NOT EXISTS modes JSONB;` for migration\n\n### Fixed\n- Fix tiny avatar for long messages\n- Security vulnerability in Chainlit: added missed sanitization to custom elements update endpoint\n\n### Changed\n- Bumped watchfiles version\n\n## [2.9.3] - 2025-12-04\n\n### Added\n- Add tests for oauth providers and messages\n- Merge metadata in chainlit data layer\n- Add native video support in markdown rendering\n- Optimize chat message rendering\n- Add language configuration option to config.toml\n- Upgrade langchain imports for v1 compatibility\n- Improve icon name formatting issues\n\n### Fixed\n- Fixed page blinking issue with header_auth\n- Set environ when restoring websocket session\n- Move hello.py to avoid import issues\n- Fix issue showing thread sharing when disabled\n- Disable Chainlit from setting logging globally\n\n## [2.9.2] - 2025-11-22\n\n### Added\n- Add tests for socket, chat context, cache, translations & oauth providers\n\n### Fixed\n- Fix copilot breaking change introduced in 2.8.5\n\n## [2.9.1] - 2025-11-20\n\n### Added\n- Add support for tabs in chat settings\n- Support markdown in watermark\n- Add italian translation to translations folder\n- Add query param prefill for chat\n- Add tests for utils, markdown, sidebar, chat settings, mcp, input widget, langchain, elements, steps, and actions\n\n\n## [2.9.0] - 2025-11-06\n\n### Added\n- Add better support for Multi-Agent implementations\n  - Nested steps are now step.input -> child step -> step.output\n  - Improved formatting and styling of Tasklist\n\n\n## [2.8.5] - 2025-11-07\n\n### Added\n- Add display_name to ChatProfile\n- Add slack reaction event callback\n- Add raw response from OAuth providers\n\n### Fixed\n- Security vulnerability in Chainlint: added missed ACL check for session initialization\n\n### Changed\n- Remove FastAPI version restrictions\n\n## [2.8.4] - 2025-10-29\n\n### Added\n- Add support for GitHub Enterprise OAuth provider\n- Explicit disable on input widgets\n\n\n### Fixed\n- Tasklist tasks are now properly reconnected to their steps/messages\n- ci: fix pnpm publish checks\n- fix: missing / in url with base path when connecting Streamable HTTP MCP\n- fix - persist custom_elements to data layer without cloud storage\n- fix: propagate IME composition events in AutoResizeTextarea\n- fix: confirm when enter\n- Fix(translation): correct French translation of chat watermark \n- fix(ui): add fallback logo if custom logo is missing\n\n## [2.8.3] - 2025-10-06\n\n### Added\n- Support for the `target` attribute in header links, which can be configured through the configuration options\n\n### Changed\n- `@chainlit/react-client` automatic publishing\n\n## [2.8.2] - 2025-10-01\n\n### Changed\n- Remove autofocus in mobile message composer\n- Improve error handling in sqlalchemy data layer `get_read_url()`\n\n### Fixed\n- Fix voice hotkey (P) triggering when typing in chat input\n- Properly finalize data layers\n- Fix `on_chat_start` not always firing\n\n## [2.8.1] - 2025-09-24\n\n### Added\n- Add German and Korean translations\n- Add support for custom_meta_url in config.toml\n\n### Changed\n- `cl.on_thread_share_view` will allow shared thread viewing if it returns `True` to enable custom/admin viewing.\n\n### Fixed\n- Removed redundant message sending in Slack when images are present.\n- Generate signed url when loading elements using SQLAlchemy data layer.\n\n## [2.8.0] - 2025-09-12\n\n### Added\n- Add ability to share threads. See documentation for how to enable it.\n  - https://docs.chainlit.io/api-reference/lifecycle-hooks/on-shared-thread-view\n- Add new chat settings: multi-select, radio-group, and checkbox\n- Add optional language parameter to set_starters\n- Add neutral Spanish translation\n- Allow sending commands from custom elements\n\n### Changed\n- Reordered message composer elements\n\n### Fixed\n- Default to plaintext code blocks for unsupported languages like CSV\n- Sort threads by updated_at field\n- Replace hardcoded strings with translation keys\n- GCP storage provider dependency is now optional\n- CI/CD fixes\n- Fixed issues with hot-reloading in dev mode (`-w` flag)\n- Take overridden config into account in audio handlers\n\n## [2.7.2] - 2025-08-26\n\n### Added\n- Added LiteralAI data layer deprecation warning\n- Added context to `@cl.on_feedback` callback\n- Added Traditional Chinese (Taiwan) translations\n- Added configurable user_env persistence to database\n  - New `persist_user_env` and `mask_user_env` field in `config.toml`\n- Added new command translations to all languages\n- Added CODEOWNERS\n\n### Fixed\n- Improved dynamic config overrides for chat profiles\n- Import GCSStorageClient only when needed to avoid requiring optional dependencies\n- Updated CONTRIBUTING.md for `uv` usage\n\n## [2.7.1.1] - 2025-08-21\n\n- Fix publishing to include frontend and copilot folders\n\n## [2.7.1] - 2025-08-20\n\n- Fix publishing to work with uv\n\n## [2.7.0] - 2025-08-20\n\n### Added\n- New ChatGPT-style command selection and improve message input handling\n- 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).\n  - 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\n  - Delete your `config.toml`, run `chainlit init`, and update your settings\n- Added copilot setup instructions for GitHub Copilot SWE Agent\n- Added Slack socket mode support\n- AskFileButton can now upload file with proper checking and it's own limits\n- Added content-disposition metadata to azure blob uploads to persist download file name\n- Migrated from poetry to uv\n\n### Fixed\n- Changed thread sorting to use updated time instead of creation time\n- Add missing headers when connecting Streamable HTTP MCP\n- Remove undocumented `CHAINLIT_CUSTOM_AUTH` environment variable used in Copilot\n\n## [2.6.9] - 2025-08-14\n\n### Added\n- Add GitHub Copilot instructions for automated PRs\n- (Slack) Add threadId for user feedback\n- (Copilot) Add new optional opened property has been added to the widget config\n\n### Fixed\n- Fix blinking cursor indicator\n- (Copilot) Rename copilot inner div id `chainlit-copilot` to `chainlit-copilot-chat` due to naming conflict with the outer div\n- Disable gzip for websocket-relaed http endpoint (Safari compatibility)\n- Prevent constant refresh on the login screen when using custom authenication\n- Fix MCP type hints\n\n## [2.6.8] - 2025-08-08\n\n### Other\n\n- Reverted PR with newline preservation in messages due to incorrect rendering in child components like lists\n\n## [2.6.7] - 2025-08-07\n\n### Fixed\n- Formatting when pasting HTML code and newlines in received messages\n\n## [2.6.6] - 2025-08-05\n\n### Added\n- Add support for emoji reaction on message received in Slack\n- Add Greek translation\n- Copy both plain text and rich text to clipboard, if available (rich text pasting to editors like Word)\n- Rename `CHAINLIT_COOKIE_PATH` to `CHAINLIT_AUTH_COOKIE_PATH` and now espect CHAINLIT_ROOT_PATH\n- Add language parameter to Copilot widget configuration\n\n### Fixed\n- Prevent HTML code in user message to be rendered as HTML instead of displaying as code\n- Properly parse `user_env` when `config.project.user_env` is empty\n\n## [2.6.5] - 2025-08-02\n\n### Fixed\n- Properly escape HTML on paste\n- Enable gzip compression for frontend\n- Address security vulnerabilities in dependencies by upgrading them to the closest safe versions\n- CI e2e tests and pnpm cache issues\n\n## [2.6.4] - 2025-08-01\n\n### Added\n- Add streamable HTTP MCP support\n- Improve e2e test stability and performance\n- Add configuration for expanded copilot mode\n- Add French translation\n\n### Fixed\n- Fix inputs/outputs for langchain callbacks\n- Fix blinking indicator for in-progress steps\n- Avoid unnecessary logo fetching when supplied in config.toml\n\n### Other\n- Bump dependencies\n\n## [2.6.3] - 2025-07-25\n\n### Added\n- Ability to send empty commands\n- Wider element view in copilot and improved styling\n- Support signed urls for elements using dynamoDB persistence\n- Support additional connection arguments in SQLAlchemy data layer\n- Added `CHAINLIT_COOKIE_PATH` environment variable to set the cookie path\n\n### Fixed\n- Message inputs formatting\n- Language pattern to allow `tzm-Latn-DZ`\n- Properly encode parentheses in markdown links\n- Fix chainlit data layer metadata upserts\n- Improve database connection handling\n- Fixed cookie path \n- Improve lanchain callbacks\n\n### Other\n- Improve robustness of E2E tests\n- Removed watermark \"Built with Chainlit\"\n\n## [2.6.2] - 2025-07-16\n\nTechnical release due to missed `frontend` and `copilot` folders in previous one.\n\n## [2.6.1] - 2025-07-15\n\n### Added\n- New `on_feedback` callback\n- Relaxed restriction on number of starters (now more than 4 can be displayed)\n\n### Fixed\n- Command persistence when `\"button\": True` is missing from command definition\n- `openai` and `mistralai` sub-modules fail due to incorrect `timestamp_utc` import\n- Temporarily reverted fix caused the following issues with Chainlit data layer:\n  - `null value in column \"metadata\" of relation \"Thread\"`\n  - `syntax error at or near \";\"`\n- Google Cloud Storage private bucket support in Chainlit data layer\n- Portals (popups, dialogs, etc.) now render correctly inside Copilot’s shadow DOM\n\n### Other\n- Removed telemetry\n- Updated versions for Node.js, Poetry, and pnpm; added Corepack support\n\n## [2.6.0] - 2025-07-01\n\n### Added\n- Add commands to starters\n- Collapse command buttons to icons for small screens\n- Add timegated custom elements\n- Added ADC support for google cloud storage adapter\n- Added scope as env variable (`OAUTH_COGNITO_SCOPE`) to Cognito auth provider\n- Add MarkdownAlert Style Switcher. Control via `alert_style` in `config.toml`.\n- Allow custom s3 endpoint for the official data layer\n- Added container prop to dialog portal in Copilot shadow DOM\n- Bump dependencies\n- Add python 3.13 support\n\n### Fixed\n- Fix chat input double-spacing issue\n- Resolve python deprecation warning for utc_now() and logger.warn\n- Fixed an issue where the portal for the ChatProfiles selector was being rendered outside the Copilot shadow DOM\n- Add mime type to element emitter\n- Handle float/Decimal conversion for DynamoDB persistence\n- Fix cancel button in Chat settings\n- Only update thread metadata when not empty\n\n### Breaking\n- **LiteralAI** is being sunset and will be removed in one of the next releases. Please migrate to the official data layer instead.\n- Telemetry is now opt-in by default and will be removed in the next release.\n\n## [2.5.5] - 2025-04-14\n\n### Added\n\n- Avatars now support `.` in their name (will be replaced with `_`).\n- Typed session accessors for user session\n- Allow set attributes for the tags of the custom_js or custom_css\n- Hovering a past chat in the sidebar will display the full title of the chat in a tooltip\n- The `X-Chainlit-Session-id` header is now automatically set to facilitate sticky sessions with websockets\n- `cl.ErrorMessage` now have a different avatar\n- The copy button is now only displayed on the final message of a run, like feedback buttons\n- CopilotFunction is now usable in custom JS\n- Header link now have an optional `display_name` to display text next to the icon\n- The default .env file loaded by chainlit is now configurable with `CHAINLIT_ENV_FILE`\n\n\n### Changed\n\n- **[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\n- The scroll to the bottom animation is now smooth\n\n## [2.4.400] - 2025-03-29\n\n### Added\n\n- `@cl.on_app_startup` and `@cl.on_app_shutdown`\n- Configuration option for chat history default open state\n- Configuration option for login page background image and filter\n- Most commonly customized ui elements now have specific IDs\n\n### Fixed\n\n- App should no longer flicker on load\n- Attachments icons for microsoft files should now correctly display\n- Pasting should no longer be duplicated\n\n## [2.4.302] - 2025-03-26\n\n### Added\n\n- Add thinking token support to langchain callback handler\n\n### Fixed\n\n- Pasting issues in the chat input\n- Rename nl-NL.json to nl.json\n\n## [2.4.301] - 2025-03-24\n\n### Fixed\n\n- Mcp button should not be displayed if `@on_mcp_connect` is not defined\n\n## [2.4.3] - 2025-03-23\n\n### Added\n\n- Canvas mode for the element side bar if title == `canvas`\n- Allow list for MCP stdio commands\n- `key` parameter to `ElementSidebar.set_elements` method\n\n### Fixed\n\n- Literal AI should now correctly store custom elements props\n- Element should correctly load from azure storage\n- Plotly elements should now take full width\n\n## [2.4.2] - 2025-03-19\n\n### Added\n\n- Hide commands button if all commands are specified as button.\n\n### Fixed\n\n- Chat profiles tooltip should no longer freeze is hover rapidly\n\n## [2.4.1] - 2025-03-13\n\n### Added\n\n- The user message auto scroll behavior is now a feature `config.features.user_message_autoscroll`\n- Stdio MCP commands now support environment variables\n\n### Fixed\n\n- Submounting a Chainlit app to a FastAPI app with a root path should now work\n\n## [2.4.0] - 2025-03-11\n\n### Changed\n\n- Chainlit now requires python `>=3.10`\n\n### Added\n\n- MCP support through `@cl.on_mcp_connect` and `@cl.on_mcp_disconnect`\n\n### Fixed\n\n- Pasting text/images into Chainlit Copilot should now work\n- OAuth redirection should work when submounting Chainlit with root path `/`\n- Successive AskUser messages should no longer collide\n\n### Removed\n\n- Outdated Haystack integration\n\n## [2.3.0] - 2025-03-09\n\n### Added\n\n- New user messages are now placed/scrolled to the top of the chat to enhance readability\n- Commands have a new optional boolean field `button` to turn them into buttons\n- Custom elements have access to a new API `sendUserMessage`\n\n### Fixed\n\n- Chainlit app using a custom root path should now work correctly when running in docker containers\n- Chat history time groups should now be sorted properly\n\n## [2.2.1] - 2025-02-14\n\n### Added\n\n- `default_open` parameter to the step decorator/class\n\n### Fixed\n- Input should not replace <,>,&\n- Starters should be disabled if no ws connection\n- Prevent orphaned thread record when deleting active conversation\n\n## [2.2.0] - 2025-02-08\n\n### Added\n\n- You can now add custom buttons in the header\n\n### Fixed\n\n- Step open/close is now animated\n- prevent unstyled flash when streaming code blocks\n- Docking/undocking scroll while streaming show now work better\n\n## [2.1.2] - 2025-02-05\n\n### Fixed\n- The default loader should now be displayed if the chat is running and no response is yet sent\n- Pasting HTML in the chat input show now work\n- React warnings and accessibility issues\n- Command filtering now works with `includes` instead of `startWith`\n- The submit button should be disabled in the chat input is empty\n\n## [2.1.1] - 2025-02-03\n\n### Fixed\n\n- Reintroduce including URL location after UI refactor\n- Ensure SAS token start time is set to UTC\n- Prevent showing 0's on resumed thread if AskAction/File was used\n- Remove 22px element ref height\n- Update Microsoft OAuth offline_access scope to be fully qualified with the prefix\n\n## [2.1.0] - 2025-01-30\n\n### Added\n\n- You can now send toasts with `cl.context.emitter.send_toast`\n- Markdown now supports alerts\n- Theme options are now translatable\n- Copilot can now load custom css\n\n### Fixed\n\n- Mounting Chainlit as a sub app should no longer break the parent's app endpoints\n- Pasting text in the chat input should now remove extra formatting and preserve new lines\n\n\n## [2.0.603] - 2025-01-28\n\n### Added\n\n- Data layer initialization to the telemetry\n\n### Fixed\n\n- Gap between the word `Used` and tool name in step name\n\n## [2.0.602] - 2025-01-27\n\n### Fixed\n\n- Chat input should now auto focus\n- When unfolding a step, the `Output` title should only show if there is an input to display\n\n## [2.0.601] - 2025-01-25\n\n### Fixed\n\n- Element sidebar should take full height\n\n## [2.0.6] - 2025-01-24\n\n### Added\n\n- The element sidebar is now controllable from the python code\n\n### Fixed\n- The auth cookie no longer has a maximal size\n- Pasting text in the chat input should now work\n- Long text in AskAction buttons are now gracefully displayed\n- Server connection error translation path\n\n## [2.0.5] - 2025-01-21\n\n### Added\n\n- Chat GPT like commands\n- Translation options. The translation schema has been simplified\n\n### Fixed\n\n- Warnings around file upload mime types\n- `uvicorn` and `packaging` version requirement have been relaxed\n\n## [2.0.4] - 2025-01-17\n\n### Added\n- Overhaul element reference link styling\n- Japanese translations\n- Improved Chinese translations\n- Translations for feedback buttons\n\n\n### Fixed\n- Cookie max age should now correctly use the config `user_session_timeout` field\n- Thread grouping in the chat history should now correctly handle timezones\n- File from `AskFileMessage` should now share ID with the data layer\n- Data layer boolean casting issues\n- Chat settings modal scrolling issue\n\n## [2.0.3] - 2025-01-14\n\n### Added\n\n- `CustomElement.update()` to update a custom element props server side\n- Translation for the copy button\n\n### Fixed\n- The official data layer should not overwrite elements anymore\n- A bug where resuming a thread would not load the thread\n- Prevent authentication before the app is fully loaded\n- Installing Chainlit from github should work again\n- `tool` steps should count as a thread start\n\n## [2.0.2] - 2025-01-10\n\n### Added\n\n- `http_cookie` is now available in the user session and websocket session\n\n### Fixed\n- Chat profile description on the welcome screen now supports custom html and latex\n- Thread history batch size has been increased to 35 to ensure scroll on a taller screens\n- Chat settings modal should now scroll if too tall\n- Errors in thread resume (like thread not found) now properly redirects to the the home page\n- Elements like Dataframe, Plotly or text should now load correctly from cloud storages\n- AskFileMessage is now usable even if spontaneous uploads are disabled\n- Remove element objects from cloud storage on thread removal (Official & SQLAlchemy data layers)\n- Fix custom element `props` storage for SQL Alchemy data layer\n\n## [2.0.1] - 2025-01-09\n\n### Added\n- `window.toggleChainlitCopilot()` to toggle the copilot\n\n### Fixed\n- Chat profiles icon and description should now be displayed on the welcome screen\n- Action should be able to trigger the first interaction\n- Raw code blocks should now be displayed correctly\n- TextInput for chat settings should now work\n- Upload attachement button should not be displayed when upload is disabled\n- Removed unused numpy dependency\n\n\n## [2.0.0] - 2025-01-06\n\nThe Chainlit UI (including the copilot) has been completely re-written with Shadcn/Tailwind. This brings several advantages:\n1. The codebase is simpler and more contribution friendly.\n2. It enabled the new custom element feature.\n3. The theme customisation is more powerful.\n\n### Added\n- Custom Elements (code your own elements)\n- `Cmd+k` thread search\n- Thread rename\n- Official PostGres open source data layer\n- New `@data_layer` decorator for configuring custom data layers declaratively\n\n### Changed\n- Authentication is now based on cookies. Cross Origins are disallowed unless added in `allow_origins` in the `config.toml` file\n- No longer need to click on `resume` to resume a thread\n- **[breaking]**: Theme customisation is now handled in `public/theme.json` instead of `config.toml`.\n- **[breaking]**: Changed fields on the `Action` class:\n  - The `value` field has replaced with `payload` which accepts a Python dict\n  - The `description` field has been renamed `tooltip`\n  - The field `icon` has been added\n  - The `collapsed` field has been removed.\n- **[breaking]**: Completely revamped audio implementation (#1401, #1410):\n  - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk`\n  - Changed default audio sampling rate from 44100 to 24000\n  - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`)\n\n### Fixed\n\n- Autoscaling of Chainlit app behind a load balancer should now work. Don't forget to enable sticky sessions\n\n## [2.1.dev0] - 2024-11-14\n\nPre-release: developer preview.\n\n### Added\n- New `@data_layer` decorator for configuring custom data layers declaratively\n- Unit tests for `get_data_layer()` and `@data_layer` functionality\n\n### Changed\n- Data layer configuration system now prioritizes `@data_layer` decorator over environment variables\n- Data layer initialization is now more explicit and testable through the decorator pattern\n- Updated example code in `/cypress/e2e/custom_data_layer` and `/cypress/e2e/data_layer` to use the new decorator\n\n### Developer Experience\n- Improved test infrastructure with new fixtures for data layer mocking\n- Added comprehensive tests for data layer configuration scenarios\n\n## [1.3.2] - 2024-11-08\n\n### Security Advisory\n**IMPORTANT**:\n- 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.\n- 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.\n\n### Security\n- **[breaking]** Updated dependencies to address critical issues (#1493):\n  - Upgraded fastapi to 0.115.3 to address CVE-2024-47874 in Starlette\n  - Upgraded starlette to 0.41.2 (required for security fix)\n  - Upgraded werkzeug to 3.0.6\n\nNote: This is a breaking change as older FastAPI versions are no longer supported.\nTo prioritize security, we opted to break with semver on this particular occasion.\n\n### Fixed\n- Resolved incorrect message ordering in UI (#1501)\n\n## [2.0rc0] - 2024-11-08\n\n### Security Advisory\n**IMPORTANT**:\n- 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.\n\n### Changed\n- **[breaking]**: Completely revamped audio implementation (#1401, #1410):\n  - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk`\n  - Changed default audio sampling rate from 44100 to 24000\n  - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`)\n  - Removed `RecordScreen` component\n- Factored storage clients into separate modules (#1363)\n\n### Added\n- Realtime audio streaming and processing (#1401, #1406, #1410):\n  - New `AudioPresence` component for visual representation\n  - Implemented `WavRecorder` and `WavStreamPlayer` classes\n  - Introduced new `on_audio_start` callback\n  - Added audio interruption functionality\n  - New audio connection signaling with `on` and `off` states\n- Interactive DataFrame display with auto-fit content using MUI Data Grid (#1373, #1467)\n- Optional websocket connection in react-client (#1379)\n- Enhanced image interaction with popup view and download option (#1402)\n- Current URL included in message payload (#1403)\n- Allow empty chat input when submitting attachments (#1261)\n\n### Fixes\n- Various backend fixes and cleanup (#1432):\n  - Use importlib.util.find_spec to check if a package is installed\n  - Use `raise... from` to wrap exceptions\n  - Fix error message in Discord integration\n  - Several minor fixups/cleanup\n\n### Development\n- Implemented ruff for linting and formatting (#1495)\n- Added mypy daemon for faster type-checking (#1495)\n- Added GitHub Actions linting (#1445)\n- Enabled direct installation from GitHub (#1423)\n- Various build script improvements (#1462)\n\n## [1.3.1] - 2024-10-25\n\n### Security Advisory\n\n- **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.\n- A comprehensive security fix will be implemented in an upcoming release.\n\n### Changed\n\n- Reverted authentication requirements for file access endpoints to restore element functionality (#1474)\n\n### Development\n\n- Work in progress on implementing HTTP-only cookie authentication for proper security (#1472)\n\n## [1.3.0] - 2024-10-22\n\n### Security\n\n- Fixed critical endpoint security vulnerabilities (#1441)\n- Enhanced authentication for file-related endpoints (#1431)\n- Upgraded frontend and backend dependencies to address security issues (#1431)\n\n### Added\n\n- SQLite support in SQLAlchemy integration (#1319)\n- Support for IETF BCP 47 language tags, enabling localized languages like es-419 (#1399)\n- Environment variables `OAUTH_<PROVIDER>_PROMPT` and `OAUTH_PROMPT` to\noverride 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).\n- Added `get_element()` method to SQLAlchemyDataLayer (#1346)\n\n### Changed\n\n- Bumped LiteralAI dependency to version 0.0.625 (#1376)\n- Optimized LiteralDataLayer for improved performance and consistency (#1376)\n- Refactored context handling in SQLAlchemy data layer (#1319)\n- Updated package metadata with correct authors, license, and documentation links (#1413)\n- Enhanced GitHub Actions workflow with restricted permissions (#1349)\n\n### Fixed\n\n- Resolved dialog boxes extending beyond window bounds (#1446)\n- Fixed tasklist functionality when Chainlit is submounted (#1433)\n- Corrected handling of `display_name` in PersistentUser during authentication (#1425)\n- Fixed SQLAlchemy identifier quoting (#1395)\n- Improved spaces handling in avatar filenames (#1418)\n\n### Development\n\n- Implemented extensive test coverage for LiteralDataLayer and SQLAlchemyDataLayer\n- Added comprehensive unit tests for file-related endpoints\n- Enhanced code organization and import structure\n- Improved Python code style and linting (#1353)\n- Resolved various small text and documentation issues (#1347, #1348)\n\n## [1.2.0] - 2024-09-16\n\n### Security\n\n- Fixed critical vulnerabilities allowing arbitrary file read access (#1326)\n- Improved path traversal protection in various endpoints (#1326)\n\n### Added\n\n- Hebrew translation JSON (#1322)\n- Translation files for Indian languages (#1321)\n- Support for displaying function calls as tools in Chain of Thought for LlamaIndexCallbackHandler (#1285)\n- Improved feedback UI with refined type handling (#1325)\n\n### Changed\n\n- Upgraded cryptography from 43.0.0 to 43.0.1 in backend dependencies (#1298)\n- Improved GitHub Actions workflow (#1301)\n- Enhanced data layer cleanup for better performance (#1288)\n- Factored out callbacks with extensive test coverage (#1292)\n- Adopted strict adherence to Semantic Versioning (SemVer)\n\n### Fixed\n\n- Websocket connection issues when submounting Chainlit (#1337)\n- Show_input functionality on chat resume for SQLAlchemy (#1221)\n- Negative feedback class incorrectness (#1332)\n- Interaction issues with Chat Profile Description Popover (#1276)\n- Centered steps within assistant messages (#1324)\n- Minor spelling errors (#1341)\n\n### Development\n\n- Added documentation for release engineering process (#1293)\n- Implemented testing for FastAPI version matrix (#1306)\n- Removed wait statements from E2E tests for improved performance (#1270)\n- Bumped dataclasses to latest version (#1291)\n- Ensured environment loading before other imports (#1328)\n\n## [1.1.404] - 2024-09-04\n\n### Security\n\n- **[breaking]**: Listen to 127.0.0.1 (localhost) instead on 0.0.0.0 (public) (#861).\n- **[breaking]**: Dropped support for Python 3.8, solving dependency resolution, addressing vulnerable dependencies (#1192, #1236, #1250).\n\n### Fixed\n\n- Frontend connection resuming after connection loss (#828).\n- Gracefully handle HTTP errors in data layers (#1232).\n- AttributeError: 'ChatCompletionChunk' object has no attribute 'get' in llama_index (#1229).\n- `edit_message` in correct place in default config, allowing users to edit messages (#1218).\n\n### Added\n\n- `CHAINLIT_APP_ROOT` environment variable to modify `APP_ROOT`, enabling the ability to set the location of `config.toml` and other setting files (#1259).\n- Poetry lockfile in GIT repository for reproducible builds (#1191).\n- pytest-based testing infrastructure, first unit tests of backend and testing on all supported Python versions (#1245 and #1271).\n- Black and isort added to dev dependencies group (#1217).\n\n## [1.1.403rc0] - 2024-08-13\n\n### Fixed\n\n- Langchain Callback handler IndexError\n- Attempt to fix websocket issues\n\n## [1.1.402] - 2024-08-07\n\n### Added\n\n- The `User` class now has a `display_name` field. It will not be persisted by the data layer.\n- The logout button will now reload the page (needed for custom auth providers)\n\n## [1.1.401] - 2024-08-02\n\n### Changed\n\n- Directly log step input args by name instead of wrapping them in \"args\" for readability.\n\n### Fixed\n\n- Langchain Callback handler ValueError('not enough values to unpack (expected 2, got 0)')\n\n## [1.1.400] - 2024-07-29\n\n### Changed\n\n- hide_cot becomes cot and has three possible values: hidden, tool_call, full\n- User feedback are now scoring an entire run instead of a specific message\n- Slack/Teams/Discord DM threads are now split by day\n- Slack DM now also use threads\n- Avatars are always displayed at the root level of the conversation\n\n### Removed\n\n- disable_feedback has been removed\n- root_message has been removed\n\n## [1.1.306] - 2024-07-03\n\n### Added\n\n- Messages are now editable. You can disable this feature with `config.features.edit_message = false`\n- `cl.chat_context` to help keeping track of the messages of the current thread\n- You can now enable debug_mode when mounting Chainlit as a sub app by setting the `CHAINLIT_DEBUG` to `true`.\n\n### Fixed\n\n- Message are now collapsible if too long\n- Only first level tool calls are displayed\n- OAuth redirection when mounting Chainlit on a FastAPI app should now work\n- The Langchain callback handler should better capture chain runs\n- The Llama Index callback handler should now work with other decorators\n\n## [1.1.305] - 2024-06-26\n\n### Added\n\n- Mistral AI instrumentation\n\n## [1.1.304] - 2024-06-21\n\n### Fixed\n\n- OAuth final redirection should account for root path if provided\n\n## [1.1.303] - 2024-06-20\n\n### Fixed\n\n- OAuth URL redirection should be correctly formed when using CHAINLIT_URL + submounted chainlit app\n\n## [1.1.302] - 2024-06-16\n\n### Added\n\n- Width and height option for the copilot bubble\n\n### Fixed\n\n- Chat profile icon in copilot should load\n- Theme should work with Copilot\n\n### Removed\n\n- Running toast when an action is running\n\n## [1.1.301] - 2024-06-14\n\n### Fixed\n\n- Azure AD oauth get_user_info not implemented error\n\n## [1.1.300] - 2024-06-13\n\n### Added\n\n- `@cl.set_starters` and `cl.Starter` to suggest conversation starters to the user\n- Teams integration\n- Expand copilot button\n- Debug mode when starting with `-d`. Only available if the data layer supports it. This replaces the Prompt Playground.\n- `default` theme config in `config.toml`\n- If only one OAuth provider is set, automatically redirect the user to it\n- Input streaming for tool calls\n\n### Changed\n\n- **[BREAKING]** Custom endpoints have been reworked. You should now mount your Chainlit app as a FastAPI subapp.\n- **[BREAKING]** Avatars have been reworked. `cl.Avatar` has been removed, instead place your avatars by name in `/public/avatars/*`\n- **[BREAKING]** The `running`, `took_one` and `took_other` translations have been replaced by `used`.\n- **[BREAKING]** `root` attribute of `cl.Step` has been removed. Use `cl.Message` to send root level messages.\n- Chain of Thought has been reworked. Only steps of type `tool` will be displayed if `hide_cot` is false\n- The `show_readme_as_default` config has been removed\n- No longer collapse root level messages\n- The blue alert \"Continuing chat\" has been removed.\n\n### Fix\n\n- The Chat Profile description should now disappear when not hovered.\n- Error handling of steps has been improved\n- No longer stream the first token twice\n- Copilot should now work as expected even if the user is closing/reopening it\n- Copilot CSS should no longer leak/be impacted by the host website CSS\n- Fix various `cl.Context` errors\n- Reworked message padding and spacing\n- Chat profile should now support non-ASCII characters (like chinese)\n\n## [1.1.202] - 2024-05-22\n\n### Added\n\n- Support for video players like youtube or vimeo\n\n### Fixed\n\n- Fix audio capture on windows browsers\n\n## [1.1.201] - 2024-05-21\n\n### Fixed\n\n- Intermediary steps button placement\n\n## [1.1.200] - 2024-05-21\n\n### Changed\n\n- User message UI has been updated\n- Loading indicator has been improved and visually updated\n- Icons have been updated\n- Dark theme is now the default\n\n### Fixed\n\n- Scroll issues on mobile browsers\n- Github button now showing\n\n## [1.1.101] - 2024-05-14\n\n### Added\n\n- The discord bot now shows \"typing\" while responding\n\n### Fixed\n\n- Discord and Slack bots should no longer fail to respond if the data layer fails\n\n## [1.1.0] - 2024-05-13\n\n### Added\n\n- You can know serve your Chainlit app as a Slack bot\n- You can know serve your Chainlit app as a Discord bot\n- `cl.on_audio_chunk` decorator to process incoming the user incoming audio stream\n- `cl.on_audio_end` decorator to react to the end of the user audio stream\n- The `cl.Audio` element now has an `auto_play` property\n- `layout` theme config, wide or default\n- `http_referer` is now available in `cl.user_session`\n\n### Changed\n\n- The UI has been revamped, especially the navigation\n- The arrow up button has been removed from the input bar, however pressing the arrow up key still opens the last inputs menu\n- The user session will no longer be persisted as metadata if > 1mb\n- **[breaking]** the `send()` method on `cl.Message` now returns the message instead of the message id\n- **[breaking]** The `multi_modal` feature has been renamed `spontaneous_file_upload` in the config\n- Element display property now defaults to `inline` instead of `side`\n- The SQL Alchemy data layer logging has been improved\n\n### Fixed\n\n- Fixed a bug disconnecting the user when loading the chat history\n- Elements based on an URL should now have a mime type\n- Stopping a task should now work better (using asyncio task.cancel)\n\n## [1.0.506] - 2024-04-30\n\n### Added\n\n- add support for multiline option in TextInput chat settings field - @kevinwmerritt\n\n### Changed\n\n- disable gzip middleware to prevent a compression issue on safari\n\n### Fixed\n\n- pasting from microsoft products generates text instead of an image\n- do not prevent thread history revalidation - @kevinwmerritt\n- display the label instead of the value for menu item - @kevinwmerritt\n\n### Added\n\n## [1.0.505] - 2024-04-23\n\n### Added\n\n- The user's browser language configuration is available in `cl.user_session.get(\"languages\")`\n- Allow html in text elements - @jdb78\n- Allow for setting a ChatProfile default - @kevinwmerritt\n\n### Changed\n\n- The thread history refreshes right after a new thread is created.\n- The thread auto-tagging feature is now opt-in using `auto_tag_thread` in the config.toml file\n\n### Fixed\n\n- Fixed incorrect step ancestor in the OpenAI instrumentation\n- Enabled having a `storage_provider` set to `None` in SQLAlchemyDataLayer - @mohamedalani\n- Correctly serialize `generation` in SQLAlchemyDataLayer - @mohamedalani\n\n## [1.0.504] - 2024-04-16\n\n### Changed\n\n- Chainlit apps should function correctly even if the data layer is down\n\n## [1.0.503] - 2024-04-15\n\n### Added\n\n- Enable persisting threads using a Custom Data Layer (through SQLAlchemy) - @hayescode\n\n### Changed\n\n- React-client: Expose `sessionId` in `useChatSession`\n- Add chat profile as thread tag metadata\n\n### Fixed\n\n- Add quotes around the chainlit create-secret CLI output to avoid any issues with special characters\n\n## [1.0.502] - 2024-04-08\n\n### Added\n\n- Actions now trigger conversation persistence\n\n## [1.0.501] - 2024-04-08\n\n### Added\n\n- Messages and steps now accept tags and metadata (useful for the data layer)\n\n### Changed\n\n- The LLama Index callback handler should now show retrieved chunks in the intermadiary steps\n- Renamed the Literal environment variable to `LITERAL_API_URL` (it used to be `LITERAL_SERVER`)\n\n### Fixed\n\n- Starting a new conversation should close the element side bar\n- Resolved security issues by upgrading starlette dependency\n\n## [1.0.500] - 2024-04-02\n\n### Added\n\n- Added a new command `chainlit lint-translations` to check that translations file are OK\n- Added new sections to the translations, like signin page\n- chainlit.md now supports translations based on the browser's language. Like chainlit_pt-BR.md\n- A health check endpoint is now available through a HEAD http call at root\n- You can now specify a custom frontend build path\n\n### Fixed\n\n- Translated will no longer flash at app load\n- Llama Index callback handler has been updated\n- File watcher should now properly refresh the app when the code changes\n- Markdown titles should now have the correct line height\n\n### Changed\n\n- `multi_modal` is now under feature in the config.toml and has more granularity\n- Feedback no longer has a -1 value. Instead a delete_feedback method has been added to the data layer\n- ThreadDict no longer has the full User object. Instead it has user_id and user_identifier fields\n\n## [1.0.400] - 2024-03-06\n\n### Added\n\n- OpenAI integration\n\n### Fixed\n\n- Langchain final answer streaming should work again\n- Elements with public URLs should be correctly persisted by the data layer\n\n### Changed\n\n- Enforce UTC DateTimes\n\n## [1.0.300] - 2024-02-19\n\n### Added\n\n- Custom js script injection\n- First token and token throughput per second metrics\n\n### Changed\n\n- The `ChatGeneration` and `CompletionGeneration` has been reworked to better match the OpenAI semantics\n\n## [1.0.200] - 2024-01-22\n\n### Added\n\n- Chainlit Copilot\n- Translations\n- Custom font\n\n### Fixed\n\n- Tasklist flickering\n\n## [1.0.101] - 2024-01-12\n\n### Fixed\n\n- Llama index callback handler should now correctly nest the intermediary steps\n- Toggling hide_cot parameter in the UI should correctly hide the `took n steps` buttons\n- `running` loading button should only be displayed once when `hide_cot` is true and a message is being streamed\n\n## [1.0.100] - 2024-01-10\n\n### Added\n\n- `on_logout` hook allowing to clear cookies when a user logs out\n\n### Changed\n\n- Chainlit apps won't crash anymore if the data layer is not reachable\n\n### Fixed\n\n- File upload now works when switching chat profiles\n- Avatar with an image no longer have a background color\n- If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted)\n- Fixed a bug preventing to open past chats\n\n## [1.0.0] - 2024-01-08\n\n### Added\n\n- Scroll down button\n- If `hide_cot` is set to `true`, a `running` loader is displayed by default under the last message when a task is running.\n\n### Changed\n\n- Avatars are now always displayed\n- Chat history sidebar has been revamped\n- Stop task button has been moved to the input bar\n\n### Fixed\n\n- If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted)\n\n## [1.0.0rc3] - 2023-12-21\n\n### Fixed\n\n- Elements are now working when authenticated\n- First interaction is correctly set when resuming a chat\n\n### Changed\n\n- The copy button is hidden if `disable_feedback` is `true`\n\n## [1.0.0rc2] - 2023-12-18\n\n### Added\n\n- Copy button under messages\n- OAuth samesite cookie policy is now configurable through the `CHAINLIT_COOKIE_SAMESITE` env var\n\n### Changed\n\n- Relax Python version requirements\n- If `hide_cot` is configured to `true`, steps will never be sent to the UI, but still persisted.\n- Message buttons are now positioned below\n\n## [1.0.0rc0] - 2023-12-12\n\n### Added\n\n- cl.Step\n\n### Changed\n\n- File upload uses HTTP instead of WS and no longer has size limitation\n- `cl.AppUser` becomes `cl.User`\n- `Prompt` has been split in `ChatGeneration` and `CompletionGeneration`\n- `Action` now display a toaster in the UI while running\n\n## [0.7.700] - 2023-11-28\n\n### Added\n\n- Support for custom HTML in message content is now an opt in feature in the config\n- Uvicorn `ws_per_message_deflate` config param is now configurable like `UVICORN_WS_PER_MESSAGE_DEFLATE=false`\n\n### Changed\n\n- Latex support is no longer enabled by default and is now a feature in the config\n\n### Fixed\n\n- Fixed LCEL memory message order in the prompt playground\n- Fixed a key error when using the file watcher (-w)\n- Fixed several user experience issues with `on_chat_resume`\n- `on_chat_end` is now always called when a chat ends\n- Switching chat profiles correctly clears previous AskMessages\n\n## [0.7.604] - 2023-11-15\n\n### Fixed\n\n- `on_chat_resume` now works properly with non json serializable objects\n- `LangchainCallbackHandler` no longer send tokens to the wrong user under high concurrency\n- Langchain cache should work when `cache` is to `true` in `config.toml`\n\n## [0.7.603] - 2023-11-15\n\n### Fixed\n\n- Markdown links special characters are no longer encoded\n- Collapsed messages no longer make the chat scroll\n- Stringified Python objects are now displayed in a Python code block\n\n## [0.7.602] - 2023-11-14\n\n### Added\n\n- Latex support (only supporting $$ notation)\n- Go back button on element page\n\n### Fixed\n\n- Code blocks should no longer flicker or display `[object object]`.\n- Now properly displaying empty messages with inlined elements\n- Fixed `Too many values to unpack error` in langchain callback\n- Langchain final streamed answer is now annotable with human feedback\n- AzureOpenAI should now work properly in the Prompt Playground\n\n### Changed\n\n- Code blocks display has been enhanced\n- Replaced aiohttp with httpx\n- Prompt Playground has been updated to work with the new openai release (v1). Including tools\n- Auth0 oauth provider has a new configurable env variable `OAUTH_AUTH0_ORIGINAL_DOMAIN`\n\n## [0.7.500] - 2023-11-07\n\n### Added\n\n- `cl.on_chat_resume` decorator to enable users to continue a conversation.\n- Support for OpenAI functions in the Prompt Playground\n- Ability to add/remove messages in the Prompt Playground\n- Plotly element to display interactive charts\n\n### Fixed\n\n- Langchain intermediate steps display are now much more readable\n- Chat history loading latency has been enhanced\n- UTF-8 characters are now correctly displayed in json code blocks\n- Select widget `items` attribute is now working properly\n- Chat profiles widget is no longer scrolling horizontally\n\n## [0.7.400] - 2023-10-27\n\n### Added\n\n- Support for Langchain Expression Language. https://docs.chainlit.io/integrations/langchain\n- UI rendering optimization to guarantee high framerate\n- Chainlit Cloud latency optimization\n- Speech recognition to type messages. https://docs.chainlit.io/backend/config/features\n- Descope OAuth provider\n\n### Changed\n\n- `LangchainCallbackHandler` is now displaying inputs and outputs of intermediate steps.\n\n### Fixed\n\n- AskUserMessage now work properly with data persistence\n- You can now use a custom okta authorization server for authentication\n\n## [0.7.3] - 2023-10-17\n\n### Added\n\n- `ChatProfile` allows to configure different agents that the user can freely chose\n- Multi modal support at the input bar level. Enabled by `features.multi_modal` in the config\n- `cl.AskUserAction` allows to block code execution until the user clicked an action.\n- Displaying readme when chat is empty is now configurable through `ui.show_readme_as_default` in the config\n\n### Changed\n\n- `cl.on_message` is no longer taking a string as parameter but rather a `cl.Message`\n\n### Fixed\n\n- Chat history is now correctly displayed on mobile\n- Azure AD OAuth authentication should now correctly display the user profile picture\n\n### Removed\n\n- `@cl.on_file_upload` is replaced by true multi modal support at the input bar level\n\n## [0.7.2] - 2023-10-10\n\n### Added\n\n- Logo is displayed in the UI header (works with custom logo)\n- Azure AD single tenant is now supported\n- `collapsed` attribute on the `Action` class\n- Latency improvements when data persistence is enabled\n\n### Changed\n\n- Chat history has been entirely reworked\n- Chat messages redesign\n- `config.ui.base_url` becomes `CHAINLIT_URL` env variable\n\n### Fixed\n\n- File watcher (-w) is now working with nested module imports\n- Unsupported character during OAuth authentication\n\n## [0.7.1] - 2023-09-29\n\n### Added\n\n- Pydantic v2 support\n- Okta auth provider\n- Auth0 auth provider\n- Prompt playground support for mix of template/formatted prompts\n- `@cl.on_chat_end` decorator\n- Textual comments to user feedback\n\n### Fixed\n\n- Langchain errors are now correctly indented\n- Langchain nested chains prompts are now correctly displayed\n- Langchain error TypeError: 'NoneType' object is not a mapping.\n- Actions are now displayed on mobile\n- Custom logo is now working as intended\n\n## [0.7.0] - 2023-09-13\n\n### Changed\n\n- Authentication is now unopinionated:\n  1. `@cl.password_auth_callback` for login/password auth\n  2. `@cl.oauth_callback` for oAuth auth\n  3. `@cl.header_auth_callback` for header auth\n- Data persistence is now enabled through `CHAINLIT_API_KEY` env variable\n\n### Removed\n\n- `@cl.auth_client_factory` (see new authentication)\n- `@cl.db_client_factory` (see new data persistence)\n\n### Added\n\n- `disable_human_feedback` parameter on `cl.Message`\n- Configurable logo\n- Configurable favicon\n- Custom CSS injection\n- GCP Vertex AI LLM provider\n- Long message collpasing feature flag\n- Enable Prompt Playground feature flag\n\n### Fixed\n\n- History page filters now work properly\n- History page does not show empty conversations anymore\n- Langchain callback handler Message errors\n\n## [0.6.4] - 2023-08-30\n\n### Added\n\n- `@cl.on_file_upload` to enable spontaneous file uploads\n- `LangchainGenericProvider` to add any Langchain LLM in the Prompt Playground\n- `cl.Message` content now support dict (previously only supported string)\n- Long messages are now collapsed by default\n\n### Fixed\n\n- Deadlock in the Llama Index callback handler\n- Langchain MessagesPlaceholder and FunctionMessage are now correctly supported\n\n## [0.6.3] - 2023-08-22\n\n### Added\n\n- Complete rework of the Prompt playground. Now supports custom LLMs, templates, variables and more\n- Enhanced Langchain final answer streaming\n- `remove_actions` method on the `Message` class\n- Button to clear message history\n\n### Fixed\n\n- Chainlit CLI performance issue\n- Llama Index v0.8+ callback handler. Now supports messages prompts\n- Tasklist display, persistence and `.remove()`\n- Custom headers growing infinitely large\n- Action callback can now handle multiple actions\n- Langflow integration load_flow_from_json\n- Video and audio elements on Safari\n\n## [0.6.2] - 2023-08-06\n\n### Added\n\n- Make the chat experience configurable with Chat Settings\n- Authenticate users based on custom headers with the Custom Auth client\n\n### Fixed\n\n- Author rename now works with all kinds of messages\n- Create message error with chainlit cloud (chenjuneking)\n\n## [0.6.1] - 2023-07-24\n\n### Added\n\n- Security improvements\n- Haystack callback handler\n- Theme customizability\n\n### Fixed\n\n- Allow multiple browser tabs to connect to one Chainlit app\n- Sidebar blocking the send button on mobile\n\n## [0.6.0] - 2023-07-20\n\n### Breaking changes\n\n- Factories, run and post process decorators are removed.\n- langchain_rename becomes author_rename and works globally\n- Message.update signature changed\n\nMigration guide available [here](https://docs.chainlit.io/guides/migration/0.6.0).\n\n### Added\n\n- Langchain final answer streaming\n- Redesign of chainlit input elements\n- Possibility to add custom endpoints to the fast api server\n- New File Element\n- Copy button in code blocks\n\n### Fixed\n\n- Persist session between websocket reconnection\n- The UI is now more mobile friendly\n- Avatar element Path parameter\n- Increased web socket message max size to 100 mb\n- Duplicated conversations in the history tab\n\n## [0.5.2] - 2023-07-10\n\n### Added\n\n- Add the video element\n\n### Fixed\n\n- Fix the inline element flashing when scrolling the page, due to un-needed re-rendering\n- Fix the orange flash effect on messages\n\n## [0.5.1] - 2023-07-06\n\n### Added\n\n- Task list element\n- Audio element\n- All elements can use the `.remove()` method to remove themselves from the UI\n- Can now use cloud auth with any data persistence mode (like local)\n- Microsoft auth\n\n### Fixed\n\n- Files in app dir are now properly served (typical use case is displaying an image in the readme)\n- Add missing attribute `size` to Pyplot element\n\n## [0.5.0] - 2023-06-28\n\n### Added\n\n- Llama Index integration. Learn more [here](https://docs.chainlit.io/integrations/llama-index).\n- Langflow integration. Learn more [here](https://docs.chainlit.io/integrations/langflow).\n\n### Fixed\n\n- AskUserMessage.remove() now works properly\n- Avatar element cannot be referenced in messages anymore\n\n## [0.4.2] - 2023-06-26\n\n### Added\n\n- 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).\n\n## [0.4.101] - 2023-06-24\n\n### Fixed\n\n- Performance improvements and bug fixes on run_sync and asyncify\n\n## [0.4.1] - 2023-06-20\n\n### Added\n\n- File watcher now reloads the app when the config is updated\n- cl.cache to avoid wasting time reloading expensive resources every time the app reloads\n\n### Fixed\n\n- Bug introduced by 0.4.0 preventing to run private apps\n- Long line content breaking the sidebar with Text elements\n- File watcher preventing to keyboard interrupt the chainlit process\n- Updated socket io to fix a security issue\n- Bug preventing config settings to be the default values for the settings in the UI\n\n## [0.4.0] - 2023-06-16\n\n### Added\n\n- Pyplot chart element\n- Config option `default_expand_messages` to enable the default expand message settings by default in the UI (breaking change)\n\n### Fixed\n\n- Scoped elements sharing names are now correctly displayed\n- Clickable Element refs are now correctly displayed, even if another ref being a substring of it exists\n\n## [0.3.0] - 2023-06-13\n\n### Added\n\n- Moving from sync to async runtime (breaking change):\n  - Support async implementation (eg openai, langchain)\n  - Performance improvements\n  - Removed patching of different libraries\n- Elements:\n  - Merged LocalImage and RemoteImage to Image (breaking change)\n  - New Avatar element to display avatars in messages\n- AskFileMessage now supports multi file uploads (small breaking change)\n- New settings interface including a new \"Expand all\" messages setting\n- The element sidebar is resizable\n\n### Fixed\n\n- Secure origin issues when running on HTTP\n- Updated the callback handler to langchain 0.0.198 latest changes\n- Filewatcher issues\n- Blank screen issues\n- Port option in the CLI does not fail anymore because of os import\n\n## [0.2.111] - 2023-06-09\n\n### Fixed\n\n- Pdf element reloading issue\n- CI is more stable\n\n## [0.2.110] - 2023-06-08\n\n### Added\n\n- `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.\n- The PDF viewer element helps you display local or remote PDF files ([documentation](https://docs.chainlit.io/api-reference/elements/pdf-viewer)).\n\n### Fixed\n\n- When running the tests, the chainlit cli is installed is installed in editable mode to run faster.\n\n## [0.2.109] - 2023-05-31\n\n### Added\n\n- URL preview for social media share\n\n### Fixed\n\n- `max_http_buffer_size` is now set to 100mb, fixing the `max_size_mb` parameter of `AskFileMessage`\n\n## [0.2.108] - 2023-05-30\n\n### Fixed\n\n- Enhanced security\n- Global element display\n- Display elements with display `page` based on their ids instead of their names\n\n## [0.2.107] - 2023-05-28\n\n### Added\n\n- Rework of the Message, AskUserMessage and AskFileMessage APIs:\n- `cl.send_message(...)` becomes `cl.Message(...).send()`\n- `cl.send_ask_user(...)` becomes `cl.AskUserMessage(...).send()`\n- `cl.send_ask_file(...)` becomes `cl.AskFileMessage(...).send()`\n- `update` and `remove` methods to the `cl.Message` class\n\n### Fixed\n\n- Blank screen for windows users (https://github.com/Chainlit/chainlit/issues/3)\n- Header navigation for mobile (https://github.com/Chainlit/chainlit/issues/12)\n\n## [0.2.106] - 2023-05-26\n\n### Added\n\n- Starting to log changes in CHANGELOG.md\n- 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 ...`.\n- A label attribute to Actions to facilitate localization.\n\n### Fixed\n\n- Clicks on inlined `RemoteImage` now opens the image in a NEW tab.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contribute to Chainlit\n\nTo contribute to Chainlit, you first need to set up the project on your local machine.\n\n## Table of Contents\n\n<!--\nGenerated using https://ecotrust-canada.github.io/markdown-toc/.\nI've copy/pasted the whole document there, and then formatted it with prettier.\n-->\n\n- [Contribute to Chainlit](#contribute-to-chainlit)\n  - [Table of Contents](#table-of-contents)\n  - [Local setup](#local-setup)\n    - [Requirements](#requirements)\n    - [Set up the repo](#set-up-the-repo)\n    - [Install dependencies](#install-dependencies)\n    - [Build Frontend](#build-frontend)\n  - [Start the Chainlit server from source](#start-the-chainlit-server-from-source)\n  - [Start the UI from source](#start-the-ui-from-source)\n  - [Run the tests](#run-the-tests)\n    - [Backend unit tests](#backend-unit-tests)\n    - [E2E tests](#e2e-tests)\n    - [Headed/debugging](#headeddebugging)\n\n## Local setup\n\n### Requirements\n\n1. Python >= `3.10`\n2. uv ([See how to install](https://docs.astral.sh/uv/getting-started/installation/))\n3. NodeJS >= `24` ([See how to install](https://nodejs.org/en/download))\n4. Pnpm ([See how to install](https://pnpm.io/installation))\n\n> **Note**\n> If you are on windows, some pnpm commands like `pnpm run formatPython` won't work. 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))\n\n### Set up the repo\n\nWith this setup you can easily code in your fork and fetch updates from the main repository.\n\n1. Go to [https://github.com/Chainlit/chainlit/fork](https://github.com/Chainlit/chainlit/fork) to fork the chainlit code into your own repository.\n2. Clone your fork locally\n\n```sh\ngit clone https://github.com/YOUR_USERNAME/YOUR_FORK.git\n```\n\n3. Go into your fork and list the current configured remote repository.\n\n```sh\n$ git remote -v\n> origin  https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch)\n> origin  https://github.com/YOUR_USERNAME/YOUR_FORK.git (push)\n```\n\n4. Specify the new remote upstream repository that will be synced with the fork.\n\n```sh\ngit remote add upstream https://github.com/Chainlit/chainlit.git\n```\n\n5. Verify the new upstream repository you've specified for your fork.\n\n```sh\n$ git remote -v\n> origin    https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch)\n> origin    https://github.com/YOUR_USERNAME/YOUR_FORK.git (push)\n> upstream  https://github.com/Chainlit/chainlit.git (fetch)\n> upstream  https://github.com/Chainlit/chainlit.git (push)\n```\n\n### Install dependencies\n\nThe following command will install Python dependencies, Node (pnpm) dependencies and build the frontend.\n\n```sh\ncd backend\nuv sync --extra tests --extra mypy --extra dev --extra custom-data\n```\n\n## Start the Chainlit server from source\n\nStart by running `backend/chainlit/sample/hello.py` as an example.\n\n```sh\ncd backend\nuv run chainlit run chainlit/sample/hello.py\n```\n\nYou should now be able to access the Chainlit app you just launched on `http://127.0.0.1:8000`.\n\nIf you've made it this far, you can now replace `chainlit/sample/hello.py` by your own target. 😎\n\n## Start the UI from source\n\nFirst, 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.\n\nThen, start the UI.\n\n```sh\ncd frontend\npnpm run dev\n```\n\nIf 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.\n\n## Run the tests\n\n### Backend unit tests\n\nThis will run the backend's unit tests.\n\n```sh\ncd backend\nuv run pytest --cov=chainlit\n```\n\n### E2E tests\n\nYou 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.\n\nThis 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:\n\n```sh\n// from root\npnpm test // will do cypress run\npnpm test -- --spec cypress/e2e/copilot // will run single test with the name copilot\npnpm test -- --spec \"cypress/e2e/copilot,cypress/e2e/data_layer\" // will run two tests with the names copilot and data_layer\npnpm test -- --spec \"cypress/e2e/**/async-*\" // will run all async tests\npnpm test -- --spec \"cypress/e2e/**/sync-*\" // will run all sync tests\npnpm test -- --spec \"cypress/e2e/**/spec.cy.ts\" // will run all usual tests\n```\n\n(Go grab a cup of something, this will take a while.)\n\nFor debugging purposes, you can use the **interactive mode** (Cypress UI). Run:\n\n```\npnpm test:interactive // runs `cypress open`\n```\n\nOnce you create a pull request, the tests will automatically run. It is a good practice to run the tests locally before pushing.\n\nMake sure to run `uv sync` again whenever you've updated the frontend!\n\n### Headed/debugging\n\nCauses the Electron browser to be shown on screen and keeps it open after tests are done.\nExtremely useful for debugging!\n\n```sh\nSINGLE_TEST=password_auth CYPRESS_OPTIONS='--headed --no-exit' pnpm test\n```"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2023- The Chainlit team. All rights reserved.\n\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "PRIVACY_POLICY.md",
    "content": "# Privacy Policy\n\nChainlit doesn't collect any data from its users after 2.6.1 release."
  },
  {
    "path": "RELENG.md",
    "content": "# Release Engineering Instructions\n\nThis document outlines the steps for maintainers to create a new release of the project.\n\n## Prerequisites\n\n- You must have maintainer permissions on the repo to create a new release.\n\n## Steps\n\n1. **Determine the new version number**:\n\n   - We use semantic versioning (major.minor.patch).\n   - Increment the major version for breaking changes, minor version for new features, patch version for bug fixes only.\n   - If unsure, discuss with the maintainers to determine if it should be a major/minor version bump or new patch version.\n\n2. **Bump the package version**:\n\n   - Update `version` in `backend/chainlit/version.py`.\n   - Update  `version` in `libs/*/package.json` if there were any changes in the corresponding directories.\n\n3. **Update the changelog**:\n\n   - Create a pull request to update the CHANGELOG.md file with the changes for the new release.\n   - Mark any breaking changes clearly.\n   - Get the changelog update PR reviewed and merged.\n\n4. **Create a new release**:\n\n   - In the GitHub repo, go to the \"Releases\" page and click \"Draft a new release\".\n   - Input the new version number as the tag (e.g. 4.0.4).\n   - Use the \"Generate release notes\" button to auto-populate the release notes from the changelog.\n   - Review the release notes, make any needed edits for clarity.\n   - If this is a full release after an RC, remove any \"-rc\" suffix from the version number.\n   - Publish the release.\n\n5. **Update any associated documentation and examples**:\n   - If needed, create PRs to update the version referenced in the docs and example code to match the newly released version.\n   - Especially important for documented breaking changes.\n\n## RC (Release Candidate) Releases\n\n- We create RC releases to allow testing before a full stable release\n- Append \"-rc\" to the version number (e.g. 4.0.4-rc)\n- Normally only bug fixes, no new features, between an RC and the final release version\n\nPing @dokterbob or @willydouhard for any questions or issues with the release process. Happy releasing!\n"
  },
  {
    "path": "backend/build.py",
    "content": "\"\"\"Build script gets called on uv/pip build.\"\"\"\n\nimport pathlib\nimport shutil\nimport subprocess\nimport sys\n\nfrom hatchling.builders.hooks.plugin.interface import BuildHookInterface\n\n\nclass BuildError(Exception):\n    \"\"\"Custom exception for build failures\"\"\"\n\n    pass\n\n\ndef run_subprocess(cmd: list[str], cwd: pathlib.Path) -> None:\n    \"\"\"\n    Run a subprocess, allowing natural signal propagation.\n\n    Args:\n        cmd: Command and arguments as a list of strings\n        cwd: Working directory for the subprocess\n    \"\"\"\n\n    print(f\"-- Running: {' '.join(cmd)}\")\n    subprocess.run(cmd, cwd=cwd, check=True)\n\n\ndef pnpm_install(project_root: pathlib.Path, pnpm_path: str):\n    run_subprocess([pnpm_path, \"install\", \"--frozen-lockfile\"], project_root)\n\n\ndef pnpm_buildui(project_root: pathlib.Path, pnpm_path: str):\n    run_subprocess([pnpm_path, \"buildUi\"], project_root)\n\n\ndef copy_directory(src: pathlib.Path, dst: pathlib.Path, description: str):\n    \"\"\"Copy directory with proper error handling\"\"\"\n    print(f\"Copying {description} from {src} to {dst}\")\n    try:\n        if dst.exists():\n            shutil.rmtree(dst)\n        dst.mkdir(parents=True)\n        shutil.copytree(src, dst, dirs_exist_ok=True)\n    except KeyboardInterrupt:\n        print(\"\\nInterrupt received during copy operation...\")\n        # Clean up partial copies\n        if dst.exists():\n            shutil.rmtree(dst)\n        raise\n    except Exception as e:\n        raise BuildError(f\"Failed to copy {src} to {dst}: {e!s}\")\n\n\ndef copy_frontend(project_root: pathlib.Path):\n    \"\"\"Copy the frontend dist directory to the backend for inclusion in the package.\"\"\"\n    backend_frontend_dir = project_root / \"backend\" / \"chainlit\" / \"frontend\" / \"dist\"\n    frontend_dist = project_root / \"frontend\" / \"dist\"\n    copy_directory(frontend_dist, backend_frontend_dir, \"frontend assets\")\n\n\ndef copy_copilot(project_root: pathlib.Path):\n    \"\"\"Copy the copilot dist directory to the backend for inclusion in the package.\"\"\"\n    backend_copilot_dir = project_root / \"backend\" / \"chainlit\" / \"copilot\" / \"dist\"\n    copilot_dist = project_root / \"libs\" / \"copilot\" / \"dist\"\n    copy_directory(copilot_dist, backend_copilot_dir, \"copilot assets\")\n\n\ndef build():\n    \"\"\"Main build function with proper error handling\"\"\"\n\n    print(\n        \"\\n-- Building frontend, this might take a while!\\n\\n\"\n        \"   If you don't need to build the frontend and just want dependencies installed, use:\\n\"\n        \"   `uv sync --no-install-project --no-editable`\\n\"\n    )\n\n    try:\n        # Find directory containing this file\n        backend_dir = pathlib.Path(__file__).resolve().parent\n        project_root = backend_dir.parent\n\n        # Dirty hack to distinguish between building wheel from sdist and from source code\n        if not (project_root / \"package.json\").exists():\n            return\n\n        pnpm = shutil.which(\"pnpm\")\n        if not pnpm:\n            raise BuildError(\"pnpm not found!\")\n\n        pnpm_install(project_root, pnpm)\n        pnpm_buildui(project_root, pnpm)\n        copy_frontend(project_root)\n        copy_copilot(project_root)\n\n    except KeyboardInterrupt:\n        print(\"\\nBuild interrupted by user\")\n        sys.exit(1)\n    except BuildError as e:\n        print(f\"\\nBuild failed: {e!s}\")\n        sys.exit(1)\n    except Exception as e:\n        print(f\"\\nUnexpected error: {e!s}\")\n        sys.exit(1)\n\n\nclass CustomBuildHook(BuildHookInterface):\n    def initialize(self, _, __):\n        build()\n"
  },
  {
    "path": "backend/chainlit/__init__.py",
    "content": "import os\n\nfrom dotenv import load_dotenv\n\n# ruff: noqa: E402\n# Keep this here to ensure imports have environment available.\nenv_file = os.getenv(\"CHAINLIT_ENV_FILE\", \".env\")\nenv_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), env_file))\n\nfrom chainlit.logger import logger\n\nif env_found:\n    logger.info(f\"Loaded {env_file} file\")\n\nimport asyncio\nfrom typing import TYPE_CHECKING, Any, Dict\n\nfrom literalai import ChatGeneration, CompletionGeneration, GenerationMessage\nfrom pydantic.dataclasses import dataclass\n\nimport chainlit.input_widget as input_widget\nfrom chainlit.action import Action\nfrom chainlit.cache import cache\nfrom chainlit.chat_context import chat_context\nfrom chainlit.chat_settings import ChatSettings\nfrom chainlit.context import context\nfrom chainlit.element import (\n    Audio,\n    CustomElement,\n    Dataframe,\n    File,\n    Image,\n    Pdf,\n    Plotly,\n    Pyplot,\n    Task,\n    TaskList,\n    TaskStatus,\n    Text,\n    Video,\n)\nfrom chainlit.message import (\n    AskActionMessage,\n    AskElementMessage,\n    AskFileMessage,\n    AskUserMessage,\n    ErrorMessage,\n    Message,\n)\nfrom chainlit.mode import Mode, ModeOption\nfrom chainlit.sidebar import ElementSidebar\nfrom chainlit.step import Step, step\nfrom chainlit.sync import make_async, run_sync\nfrom chainlit.types import (\n    ChatProfile,\n    InputAudioChunk,\n    OutputAudioChunk,\n    Starter,\n    StarterCategory,\n)\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.user_session import user_session\nfrom chainlit.utils import make_module_getattr\nfrom chainlit.version import __version__\n\nfrom .callbacks import (\n    action_callback,\n    author_rename,\n    data_layer,\n    header_auth_callback,\n    oauth_callback,\n    on_app_shutdown,\n    on_app_startup,\n    on_audio_chunk,\n    on_audio_end,\n    on_audio_start,\n    on_chat_end,\n    on_chat_resume,\n    on_chat_start,\n    on_feedback,\n    on_logout,\n    on_mcp_connect,\n    on_mcp_disconnect,\n    on_message,\n    on_settings_edit,\n    on_settings_update,\n    on_shared_thread_view,\n    on_slack_reaction_added,\n    on_stop,\n    on_window_message,\n    password_auth_callback,\n    send_window_message,\n    set_chat_profiles,\n    set_starter_categories,\n    set_starters,\n)\n\nif TYPE_CHECKING:\n    from chainlit.langchain.callbacks import (\n        AsyncLangchainCallbackHandler,\n        LangchainCallbackHandler,\n    )\n    from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler\n    from chainlit.mistralai import instrument_mistralai\n    from chainlit.openai import instrument_openai\n    from chainlit.semantic_kernel import SemanticKernelFilter\n\n\ndef sleep(duration: int):\n    \"\"\"\n    Sleep for a given duration.\n    Args:\n        duration (int): The duration in seconds.\n    \"\"\"\n    return asyncio.sleep(duration)\n\n\n@dataclass()\nclass CopilotFunction:\n    name: str\n    args: Dict[str, Any]\n\n    def acall(self):\n        return context.emitter.send_call_fn(self.name, self.args)\n\n\n__getattr__ = make_module_getattr(\n    {\n        \"LangchainCallbackHandler\": \"chainlit.langchain.callbacks\",\n        \"AsyncLangchainCallbackHandler\": \"chainlit.langchain.callbacks\",\n        \"LlamaIndexCallbackHandler\": \"chainlit.llama_index.callbacks\",\n        \"instrument_openai\": \"chainlit.openai\",\n        \"instrument_mistralai\": \"chainlit.mistralai\",\n        \"SemanticKernelFilter\": \"chainlit.semantic_kernel\",\n        \"server\": \"chainlit.server\",\n    }\n)\n\n__all__ = [\n    \"Action\",\n    \"AskActionMessage\",\n    \"AskElementMessage\",\n    \"AskFileMessage\",\n    \"AskUserMessage\",\n    \"AsyncLangchainCallbackHandler\",\n    \"Audio\",\n    \"ChatGeneration\",\n    \"ChatProfile\",\n    \"ChatSettings\",\n    \"CompletionGeneration\",\n    \"CopilotFunction\",\n    \"CustomElement\",\n    \"Dataframe\",\n    \"ElementSidebar\",\n    \"ErrorMessage\",\n    \"File\",\n    \"GenerationMessage\",\n    \"Image\",\n    \"InputAudioChunk\",\n    \"LangchainCallbackHandler\",\n    \"LlamaIndexCallbackHandler\",\n    \"Message\",\n    \"Mode\",\n    \"ModeOption\",\n    \"OutputAudioChunk\",\n    \"Pdf\",\n    \"PersistedUser\",\n    \"Plotly\",\n    \"Pyplot\",\n    \"SemanticKernelFilter\",\n    \"Starter\",\n    \"StarterCategory\",\n    \"Step\",\n    \"Task\",\n    \"TaskList\",\n    \"TaskStatus\",\n    \"Text\",\n    \"User\",\n    \"Video\",\n    \"__version__\",\n    \"action_callback\",\n    \"author_rename\",\n    \"cache\",\n    \"chat_context\",\n    \"context\",\n    \"data_layer\",\n    \"header_auth_callback\",\n    \"input_widget\",\n    \"instrument_mistralai\",\n    \"instrument_openai\",\n    \"make_async\",\n    \"oauth_callback\",\n    \"on_app_shutdown\",\n    \"on_app_startup\",\n    \"on_audio_chunk\",\n    \"on_audio_end\",\n    \"on_audio_start\",\n    \"on_chat_end\",\n    \"on_chat_resume\",\n    \"on_chat_start\",\n    \"on_feedback\",\n    \"on_logout\",\n    \"on_mcp_connect\",\n    \"on_mcp_disconnect\",\n    \"on_message\",\n    \"on_settings_edit\",\n    \"on_settings_update\",\n    \"on_shared_thread_view\",\n    \"on_slack_reaction_added\",\n    \"on_stop\",\n    \"on_window_message\",\n    \"password_auth_callback\",\n    \"run_sync\",\n    \"send_window_message\",\n    \"set_chat_profiles\",\n    \"set_starter_categories\",\n    \"set_starters\",\n    \"sleep\",\n    \"step\",\n    \"user_session\",\n]\n\n\ndef __dir__():\n    return __all__\n"
  },
  {
    "path": "backend/chainlit/__main__.py",
    "content": "from chainlit.cli import cli\n\nif __name__ == \"__main__\":\n    cli(prog_name=\"chainlit\")\n"
  },
  {
    "path": "backend/chainlit/_utils.py",
    "content": "\"\"\"Util functions which are explicitly not part of the public API.\"\"\"\n\nfrom pathlib import Path\n\n\ndef is_path_inside(child_path: Path, parent_path: Path) -> bool:\n    \"\"\"Check if the child path is inside the parent path.\"\"\"\n    return parent_path.resolve() in child_path.resolve().parents\n"
  },
  {
    "path": "backend/chainlit/action.py",
    "content": "import uuid\nfrom typing import Dict, Optional\n\nfrom dataclasses_json import DataClassJsonMixin\nfrom pydantic import Field\nfrom pydantic.dataclasses import dataclass\n\nfrom chainlit.context import context\n\n\n@dataclass\nclass Action(DataClassJsonMixin):\n    # Name of the action, this should be used in the action_callback\n    name: str\n    # The parameters to call this action with.\n    payload: Dict\n    # The label of the action. This is what the user will see.\n    label: str = \"\"\n    # The tooltip of the action button. This is what the user will see when they hover the action.\n    tooltip: str = \"\"\n    # The lucid icon name for this action.\n    icon: Optional[str] = None\n    # This should not be set manually, only used internally.\n    forId: Optional[str] = None\n    # The ID of the action\n    id: str = Field(default_factory=lambda: str(uuid.uuid4()))\n\n    async def send(self, for_id: str):\n        self.forId = for_id\n        await context.emitter.emit(\"action\", self.to_dict())\n\n    async def remove(self):\n        await context.emitter.emit(\"remove_action\", self.to_dict())\n"
  },
  {
    "path": "backend/chainlit/auth/__init__.py",
    "content": "import os\n\nfrom fastapi import Depends, HTTPException\n\nfrom chainlit.config import config\nfrom chainlit.data import get_data_layer\nfrom chainlit.logger import logger\nfrom chainlit.oauth_providers import get_configured_oauth_providers\n\nfrom .cookie import (\n    OAuth2PasswordBearerWithCookie,\n    clear_auth_cookie,\n    get_token_from_cookies,\n    set_auth_cookie,\n)\nfrom .jwt import create_jwt, decode_jwt, get_jwt_secret\n\nreuseable_oauth = OAuth2PasswordBearerWithCookie(tokenUrl=\"/login\", auto_error=False)\n\n\ndef ensure_jwt_secret():\n    if require_login() and get_jwt_secret() is None:\n        raise ValueError(\n            \"You must provide a JWT secret in the environment to use authentication. Run `chainlit create-secret` to generate one.\"\n        )\n\n\ndef is_oauth_enabled():\n    return config.code.oauth_callback and len(get_configured_oauth_providers()) > 0\n\n\ndef require_login():\n    return (\n        bool(os.environ.get(\"CHAINLIT_CUSTOM_AUTH\"))\n        or config.code.password_auth_callback is not None\n        or config.code.header_auth_callback is not None\n        or is_oauth_enabled()\n    )\n\n\ndef get_configuration():\n    return {\n        \"requireLogin\": require_login(),\n        \"passwordAuth\": config.code.password_auth_callback is not None,\n        \"headerAuth\": config.code.header_auth_callback is not None,\n        \"oauthProviders\": (\n            get_configured_oauth_providers() if is_oauth_enabled() else []\n        ),\n        \"default_theme\": config.ui.default_theme,\n        \"ui\": {\n            \"login_page_image\": config.ui.login_page_image,\n            \"login_page_image_filter\": config.ui.login_page_image_filter,\n            \"login_page_image_dark_filter\": config.ui.login_page_image_dark_filter,\n        },\n    }\n\n\nasync def authenticate_user(token: str = Depends(reuseable_oauth)):\n    try:\n        user = decode_jwt(token)\n    except Exception as e:\n        raise HTTPException(\n            status_code=401, detail=\"Invalid authentication token\"\n        ) from e\n\n    if data_layer := get_data_layer():\n        # Get or create persistent user if we've a data layer available.\n        try:\n            persisted_user = await data_layer.get_user(user.identifier)\n            if persisted_user is None:\n                persisted_user = await data_layer.create_user(user)\n                assert persisted_user\n        except Exception as e:\n            logger.exception(\"Unable to get persisted_user from data layer: %s\", e)\n            return user\n\n        if user and user.display_name:\n            # Copy ephemeral display_name from authenticated user to persistent user.\n            persisted_user.display_name = user.display_name\n\n        return persisted_user\n\n    return user\n\n\nasync def get_current_user(token: str = Depends(reuseable_oauth)):\n    if not require_login():\n        return None\n\n    return await authenticate_user(token)\n\n\n__all__ = [\n    \"clear_auth_cookie\",\n    \"create_jwt\",\n    \"get_configuration\",\n    \"get_current_user\",\n    \"get_token_from_cookies\",\n    \"set_auth_cookie\",\n]\n"
  },
  {
    "path": "backend/chainlit/auth/cookie.py",
    "content": "import os\nfrom typing import Literal, Optional, cast\n\nfrom fastapi import Request, Response\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.security.base import SecurityBase\nfrom fastapi.security.utils import get_authorization_scheme_param\nfrom starlette.status import HTTP_401_UNAUTHORIZED\n\nfrom chainlit.config import config\n\n\"\"\" Module level cookie settings. \"\"\"\n_cookie_samesite = cast(\n    Literal[\"lax\", \"strict\", \"none\"],\n    os.environ.get(\"CHAINLIT_COOKIE_SAMESITE\", \"lax\"),\n)\n\nassert _cookie_samesite in [\n    \"lax\",\n    \"strict\",\n    \"none\",\n], (\n    \"Invalid value for CHAINLIT_COOKIE_SAMESITE. Must be one of 'lax', 'strict' or 'none'.\"\n)\n_cookie_secure = _cookie_samesite == \"none\"\nif _cookie_root_path := os.environ.get(\"CHAINLIT_ROOT_PATH\", None):\n    _cookie_path = os.environ.get(_cookie_root_path, \"/\")\nelse:\n    _cookie_path = os.environ.get(\"CHAINLIT_AUTH_COOKIE_PATH\", \"/\")\n_state_cookie_lifetime = int(\n    os.environ.get(\"CHAINLIT_STATE_COOKIE_LIFETIME\", str(3 * 60))\n)\n_auth_cookie_name = os.environ.get(\"CHAINLIT_AUTH_COOKIE_NAME\", \"access_token\")\n_state_cookie_name = \"oauth_state\"\n\n\nclass OAuth2PasswordBearerWithCookie(SecurityBase):\n    \"\"\"\n    OAuth2 password flow with cookie support with fallback to bearer token.\n    \"\"\"\n\n    def __init__(\n        self,\n        tokenUrl: str,\n        scheme_name: Optional[str] = None,\n        auto_error: bool = True,\n    ):\n        self.tokenUrl = tokenUrl\n        self.scheme_name = scheme_name or self.__class__.__name__\n        self.auto_error = auto_error\n\n    async def __call__(self, request: Request) -> Optional[str]:\n        # First try to get the token from the cookie\n        token = get_token_from_cookies(request.cookies)\n\n        # If no cookie, try the Authorization header as fallback\n        if not token:\n            # TODO: Only bother to check if cookie auth is explicitly disabled.\n            authorization = request.headers.get(\"Authorization\")\n            if authorization:\n                scheme, token = get_authorization_scheme_param(authorization)\n                if scheme.lower() != \"bearer\":\n                    if self.auto_error:\n                        raise HTTPException(\n                            status_code=HTTP_401_UNAUTHORIZED,\n                            detail=\"Invalid authentication credentials\",\n                            headers={\"WWW-Authenticate\": \"Bearer\"},\n                        )\n                    else:\n                        return None\n            else:\n                if self.auto_error:\n                    raise HTTPException(\n                        status_code=HTTP_401_UNAUTHORIZED,\n                        detail=\"Not authenticated\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n                else:\n                    return None\n\n        return token\n\n\ndef _get_chunked_cookie(cookies: dict[str, str], name: str) -> Optional[str]:\n    # Gather all auth_chunk_i cookies, sorted by their index\n    chunk_parts = []\n\n    i = 0\n    while True:\n        cookie_key = f\"{_auth_cookie_name}_{i}\"\n        if cookie_key not in cookies:\n            break\n\n        chunk_parts.append(cookies[cookie_key])\n        i += 1\n\n    joined = \"\".join(chunk_parts)\n\n    return joined if joined != \"\" else None\n\n\ndef get_token_from_cookies(cookies: dict[str, str]) -> Optional[str]:\n    \"\"\"\n    Read all chunk cookies and reconstruct the token\n    \"\"\"\n\n    # Default/unchunked cookies\n    if value := cookies.get(_auth_cookie_name):\n        return value\n\n    return _get_chunked_cookie(cookies, _auth_cookie_name)\n\n\ndef set_auth_cookie(request: Request, response: Response, token: str):\n    \"\"\"\n    Helper function to set the authentication cookie with secure parameters\n    and remove any leftover chunks from a previously larger token.\n    \"\"\"\n\n    _chunk_size = 3000\n\n    existing_cookies = {\n        k for k in request.cookies.keys() if k.startswith(_auth_cookie_name)\n    }\n\n    if len(token) > _chunk_size:\n        chunks = [token[i : i + _chunk_size] for i in range(0, len(token), _chunk_size)]\n\n        for i, chunk in enumerate(chunks):\n            k = f\"{_auth_cookie_name}_{i}\"\n\n            response.set_cookie(\n                key=k,\n                value=chunk,\n                httponly=True,\n                secure=_cookie_secure,\n                samesite=_cookie_samesite,\n                max_age=config.project.user_session_timeout,\n            )\n\n            existing_cookies.discard(k)\n    else:\n        # Default (shorter cookies)\n        response.set_cookie(\n            key=_auth_cookie_name,\n            value=token,\n            httponly=True,\n            secure=_cookie_secure,\n            samesite=_cookie_samesite,\n            max_age=config.project.user_session_timeout,\n        )\n\n        existing_cookies.discard(_auth_cookie_name)\n\n    # Delete remaining prior cookies/cookie chunks\n    for k in existing_cookies:\n        response.delete_cookie(\n            key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite\n        )\n\n\ndef clear_auth_cookie(request: Request, response: Response):\n    \"\"\"\n    Helper function to clear the authentication cookie\n    \"\"\"\n\n    existing_cookies = {\n        k for k in request.cookies.keys() if k.startswith(_auth_cookie_name)\n    }\n\n    for k in existing_cookies:\n        response.delete_cookie(\n            key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite\n        )\n\n\ndef set_oauth_state_cookie(response: Response, token: str):\n    response.set_cookie(\n        _state_cookie_name,\n        token,\n        httponly=True,\n        samesite=_cookie_samesite,\n        secure=_cookie_secure,\n        max_age=_state_cookie_lifetime,\n    )\n\n\ndef validate_oauth_state_cookie(request: Request, state: str):\n    \"\"\"Check the state from the oauth provider against the browser cookie.\"\"\"\n\n    oauth_state = request.cookies.get(_state_cookie_name)\n\n    if oauth_state != state:\n        raise Exception(\"oauth state does not correspond\")\n\n\ndef clear_oauth_state_cookie(response: Response):\n    \"\"\"Oauth complete, delete state token.\"\"\"\n    response.delete_cookie(_state_cookie_name)  # Do we set path here?\n"
  },
  {
    "path": "backend/chainlit/auth/jwt.py",
    "content": "import os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict, Optional\n\nimport jwt as pyjwt\n\nfrom chainlit.config import config\nfrom chainlit.user import User\n\n\ndef get_jwt_secret() -> Optional[str]:\n    return os.environ.get(\"CHAINLIT_AUTH_SECRET\")\n\n\ndef create_jwt(data: User) -> str:\n    to_encode: Dict[str, Any] = data.to_dict()\n    to_encode.update(\n        {\n            \"exp\": datetime.now(timezone.utc)\n            + timedelta(seconds=config.project.user_session_timeout),\n            \"iat\": datetime.now(timezone.utc),  # Add issued at time\n        }\n    )\n\n    secret = get_jwt_secret()\n    assert secret\n    encoded_jwt = pyjwt.encode(to_encode, secret, algorithm=\"HS256\")\n    return encoded_jwt\n\n\ndef decode_jwt(token: str) -> User:\n    secret = get_jwt_secret()\n    assert secret\n\n    dict = pyjwt.decode(\n        token,\n        secret,\n        algorithms=[\"HS256\"],\n        options={\"verify_signature\": True},\n    )\n    del dict[\"exp\"]\n    return User(**dict)\n"
  },
  {
    "path": "backend/chainlit/cache.py",
    "content": "import importlib.util\nimport os\nimport threading\nfrom typing import Any\n\nfrom chainlit.config import config\nfrom chainlit.logger import logger\n\n\ndef init_lc_cache():\n    use_cache = config.project.cache is True and config.run.no_cache is False\n\n    if use_cache and importlib.util.find_spec(\"langchain\") is not None:\n        from langchain.cache import SQLiteCache\n        from langchain.globals import set_llm_cache\n\n        if config.project.lc_cache_path is not None:\n            set_llm_cache(SQLiteCache(database_path=config.project.lc_cache_path))\n\n            if not os.path.exists(config.project.lc_cache_path):\n                logger.info(\n                    f\"LangChain cache created at: {config.project.lc_cache_path}\"\n                )\n\n\n_cache: dict[tuple, Any] = {}\n_cache_lock = threading.Lock()\n\n\ndef cache(func):\n    def wrapper(*args, **kwargs):\n        # Create a cache key based on the function name, arguments, and keyword arguments\n        cache_key = (\n            (func.__name__,) + args + tuple((k, v) for k, v in sorted(kwargs.items()))\n        )\n\n        with _cache_lock:\n            # Check if the result is already in the cache\n            if cache_key not in _cache:\n                # If not, call the function and store the result in the cache\n                _cache[cache_key] = func(*args, **kwargs)\n\n        return _cache[cache_key]\n\n    return wrapper\n"
  },
  {
    "path": "backend/chainlit/callbacks.py",
    "content": "import inspect\nfrom typing import Any, Awaitable, Callable, Dict, List, Optional, Union, overload\n\nfrom fastapi import Request, Response\nfrom mcp import ClientSession\nfrom starlette.datastructures import Headers\n\nfrom chainlit.action import Action\nfrom chainlit.config import config\nfrom chainlit.context import context\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.mcp import McpConnection\nfrom chainlit.message import Message\nfrom chainlit.oauth_providers import get_configured_oauth_providers\nfrom chainlit.step import Step, step\nfrom chainlit.types import ChatProfile, Starter, StarterCategory, ThreadDict\nfrom chainlit.user import User\nfrom chainlit.utils import wrap_user_function\n\n\ndef on_app_startup(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable:\n    \"\"\"\n    Hook to run code when the Chainlit application starts.\n    Useful for initializing resources, loading models, setting up database connections, etc.\n    The function can be synchronous or asynchronous.\n\n    Args:\n        func (Callable[[], Union[None, Awaitable[None]]]): The startup hook to execute. Takes no arguments.\n\n    Example:\n        @cl.on_app_startup\n        async def startup():\n            print(\"Application is starting!\")\n            # Initialize resources here\n\n    Returns:\n        Callable[[], Union[None, Awaitable[None]]]: The decorated startup hook.\n    \"\"\"\n    config.code.on_app_startup = wrap_user_function(func, with_task=False)\n    return func\n\n\ndef on_app_shutdown(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable:\n    \"\"\"\n    Hook to run code when the Chainlit application shuts down.\n    Useful for cleaning up resources, closing connections, saving state, etc.\n    The function can be synchronous or asynchronous.\n\n    Args:\n        func (Callable[[], Union[None, Awaitable[None]]]): The shutdown hook to execute. Takes no arguments.\n\n    Example:\n        @cl.on_app_shutdown\n        async def shutdown():\n            print(\"Application is shutting down!\")\n            # Clean up resources here\n\n    Returns:\n        Callable[[], Union[None, Awaitable[None]]]: The decorated shutdown hook.\n    \"\"\"\n    config.code.on_app_shutdown = wrap_user_function(func, with_task=False)\n    return func\n\n\ndef password_auth_callback(\n    func: Callable[[str, str], Awaitable[Optional[User]]],\n) -> Callable:\n    \"\"\"\n    Framework agnostic decorator to authenticate the user.\n\n    Args:\n        func (Callable[[str, str], Awaitable[Optional[User]]]): The authentication callback to execute. Takes the email and password as parameters.\n\n    Example:\n        @cl.password_auth_callback\n        async def password_auth_callback(username: str, password: str) -> Optional[User]:\n\n    Returns:\n        Callable[[str, str], Awaitable[Optional[User]]]: The decorated authentication callback.\n    \"\"\"\n\n    config.code.password_auth_callback = wrap_user_function(func)\n    return func\n\n\ndef header_auth_callback(\n    func: Callable[[Headers], Awaitable[Optional[User]]],\n) -> Callable:\n    \"\"\"\n    Framework agnostic decorator to authenticate the user via a header\n\n    Args:\n        func (Callable[[Headers], Awaitable[Optional[User]]]): The authentication callback to execute.\n\n    Example:\n        @cl.header_auth_callback\n        async def header_auth_callback(headers: Headers) -> Optional[User]:\n\n    Returns:\n        Callable[[Headers], Awaitable[Optional[User]]]: The decorated authentication callback.\n    \"\"\"\n\n    config.code.header_auth_callback = wrap_user_function(func)\n    return func\n\n\ndef oauth_callback(\n    func: Callable[\n        [str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]\n    ],\n) -> Callable:\n    \"\"\"\n    Framework agnostic decorator to authenticate the user via oauth\n\n    Args:\n        func (Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]): The authentication callback to execute.\n\n    Example:\n        @cl.oauth_callback\n        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]:\n\n    Returns:\n        Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]: The decorated authentication callback.\n    \"\"\"\n\n    if len(get_configured_oauth_providers()) == 0:\n        raise ValueError(\n            \"You must set the environment variable for at least one oauth provider to use oauth authentication.\"\n        )\n\n    config.code.oauth_callback = wrap_user_function(func)\n    return func\n\n\ndef on_logout(func: Callable[[Request, Response], Any]) -> Callable:\n    \"\"\"\n    Function called when the user logs out.\n    Takes the FastAPI request and response as parameters.\n    \"\"\"\n\n    config.code.on_logout = wrap_user_function(func)\n    return func\n\n\ndef on_message(func: Callable) -> Callable:\n    \"\"\"\n    Framework agnostic decorator to react to messages coming from the UI.\n    The decorated function is called every time a new message is received.\n\n    Args:\n        func (Callable[[Message], Any]): The function to be called when a new message is received. Takes a cl.Message.\n\n    Returns:\n        Callable[[str], Any]: The decorated on_message function.\n    \"\"\"\n\n    async def with_parent_id(message: Message):\n        async with Step(name=\"on_message\", type=\"run\", parent_id=message.id) as s:\n            s.input = message.content\n            if len(inspect.signature(func).parameters) > 0:\n                await func(message)\n            else:\n                await func()\n\n    config.code.on_message = wrap_user_function(with_parent_id)\n    return func\n\n\nasync def send_window_message(data: Any):\n    \"\"\"\n    Send custom data to the host window via a window.postMessage event.\n\n    Args:\n        data (Any): The data to send with the event.\n    \"\"\"\n    await context.emitter.send_window_message(data)\n\n\ndef on_window_message(func: Callable[[str], Any]) -> Callable:\n    \"\"\"\n    Hook to react to javascript postMessage events coming from the UI.\n\n    Args:\n        func (Callable[[str], Any]): The function to be called when a window message is received.\n                                     Takes the message content as a string parameter.\n\n    Returns:\n        Callable[[str], Any]: The decorated on_window_message function.\n    \"\"\"\n    config.code.on_window_message = wrap_user_function(func)\n    return func\n\n\ndef on_chat_start(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the user websocket connection event.\n\n    Args:\n        func (Callable[], Any]): The connection hook to execute.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_chat_start = wrap_user_function(\n        step(func, name=\"on_chat_start\", type=\"run\"), with_task=True\n    )\n    return func\n\n\ndef on_chat_resume(func: Callable[[ThreadDict], Any]) -> Callable:\n    \"\"\"\n    Hook to react to resume websocket connection event.\n\n    Args:\n        func (Callable[], Any]): The connection hook to execute.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_chat_resume = wrap_user_function(func, with_task=True)\n    return func\n\n\n@overload\ndef set_chat_profiles(\n    func: Callable[[Optional[\"User\"]], Awaitable[List[\"ChatProfile\"]]],\n) -> Callable[[Optional[\"User\"]], Awaitable[List[\"ChatProfile\"]]]: ...\n\n\n@overload\ndef set_chat_profiles(\n    func: Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"ChatProfile\"]]],\n) -> Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"ChatProfile\"]]]: ...\n\n\ndef set_chat_profiles(func):\n    \"\"\"\n    Programmatic declaration of the available chat profiles (can depend on the User from the session if authentication is setup).\n\n    Args:\n        func (Callable[[Optional[\"User\"]], Awaitable[List[\"ChatProfile\"]]]): The function declaring the chat profiles.\n\n    Returns:\n        Callable[[Optional[\"User\"]], Awaitable[List[\"ChatProfile\"]]]: The decorated function.\n    \"\"\"\n\n    config.code.set_chat_profiles = wrap_user_function(func)\n    return func\n\n\n@overload\ndef set_starters(\n    func: Callable[[Optional[\"User\"]], Awaitable[List[\"Starter\"]]],\n) -> Callable[[Optional[\"User\"]], Awaitable[List[\"Starter\"]]]: ...\n\n\n@overload\ndef set_starters(\n    func: Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"Starter\"]]],\n) -> Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"Starter\"]]]: ...\n\n\ndef set_starters(func):\n    \"\"\"\n    Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup).\n\n    Args:\n        func (Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"Starter\"]]]): The function declaring the starters with optional user and language arguments.\n\n    Returns:\n        Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"Starter\"]]]: The decorated function.\n    \"\"\"\n\n    config.code.set_starters = wrap_user_function(func)\n    return func\n\n\n@overload\ndef set_starter_categories(\n    func: Callable[[Optional[\"User\"]], Awaitable[List[\"StarterCategory\"]]],\n) -> Callable[[Optional[\"User\"]], Awaitable[List[\"StarterCategory\"]]]: ...\n\n\n@overload\ndef set_starter_categories(\n    func: Callable[\n        [Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"StarterCategory\"]]\n    ],\n) -> Callable[\n    [Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"StarterCategory\"]]\n]: ...\n\n\ndef set_starter_categories(func):\n    \"\"\"\n    Programmatic declaration of starter categories with grouped starters.\n\n    Args:\n        func (Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"StarterCategory\"]]]): The function declaring the starter categories with optional user and language arguments.\n\n    Returns:\n        Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"StarterCategory\"]]]: The decorated function.\n    \"\"\"\n\n    config.code.set_starter_categories = wrap_user_function(func)\n    return func\n\n\ndef on_chat_end(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the user websocket disconnect event.\n\n    Args:\n        func (Callable[], Any]): The disconnect hook to execute.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_chat_end = wrap_user_function(func, with_task=True)\n    return func\n\n\ndef on_audio_start(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the user initiating audio.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_audio_start = wrap_user_function(func, with_task=False)\n    return func\n\n\ndef on_audio_chunk(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the audio chunks being sent.\n\n    Args:\n        chunk (InputAudioChunk): The audio chunk being sent.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_audio_chunk = wrap_user_function(func, with_task=False)\n    return func\n\n\ndef on_audio_end(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the audio stream ending. This is called after the last audio chunk is sent.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_audio_end = wrap_user_function(\n        step(func, name=\"on_audio_end\", type=\"run\"), with_task=True\n    )\n    return func\n\n\ndef author_rename(\n    func: Callable[[str], Awaitable[str]],\n) -> Callable[[str], Awaitable[str]]:\n    \"\"\"\n    Useful to rename the author of message to display more friendly author names in the UI.\n    Args:\n        func (Callable[[str], Awaitable[str]]): The function to be called to rename an author. Takes the original author name as parameter.\n\n    Returns:\n        Callable[[Any, str], Awaitable[Any]]: The decorated function.\n    \"\"\"\n\n    config.code.author_rename = wrap_user_function(func)\n    return func\n\n\ndef on_mcp_connect(\n    func: Callable[[McpConnection, ClientSession], Awaitable[None]],\n) -> Callable[[McpConnection, ClientSession], Awaitable[None]]:\n    \"\"\"\n    Called everytime an MCP is connected\n    \"\"\"\n\n    config.code.on_mcp_connect = wrap_user_function(func)\n    return func\n\n\ndef on_mcp_disconnect(\n    func: Callable[[str, ClientSession], Awaitable[None]],\n) -> Callable[[str, ClientSession], Awaitable[None]]:\n    \"\"\"\n    Called everytime an MCP is disconnected\n    \"\"\"\n\n    config.code.on_mcp_disconnect = wrap_user_function(func)\n    return func\n\n\ndef on_stop(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to the user stopping a thread.\n\n    Args:\n        func (Callable[[], Any]): The stop hook to execute.\n\n    Returns:\n        Callable[[], Any]: The decorated stop hook.\n    \"\"\"\n\n    config.code.on_stop = wrap_user_function(func)\n    return func\n\n\ndef action_callback(name: str) -> Callable:\n    \"\"\"\n    Callback to call when an action is clicked in the UI.\n\n    Args:\n        func (Callable[[Action], Any]): The action callback to execute. First parameter is the action.\n    \"\"\"\n\n    def decorator(func: Callable[[Action], Any]):\n        config.code.action_callbacks[name] = wrap_user_function(func, with_task=False)\n        return func\n\n    return decorator\n\n\ndef on_settings_update(\n    func: Callable[[Dict[str, Any]], Any],\n) -> Callable[[Dict[str, Any]], Any]:\n    \"\"\"\n    Hook to react to the user changing any settings.\n\n    Args:\n        func (Callable[], Any]): The hook to execute after settings were changed.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_settings_update = wrap_user_function(func, with_task=True)\n    return func\n\n\ndef on_settings_edit(\n    func: Callable[[Dict[str, Any]], Any],\n) -> Callable[[Dict[str, Any]], Any]:\n    \"\"\"\n    Hook to react to the user editing any settings (on the fly).\n\n    Args:\n        func (Callable[], Any]): The hook to execute while settings are being edited.\n\n    Returns:\n        Callable[], Any]: The decorated hook.\n    \"\"\"\n\n    config.code.on_settings_edit = wrap_user_function(func, with_task=True)\n    return func\n\n\ndef data_layer(\n    func: Callable[[], BaseDataLayer],\n) -> Callable[[], BaseDataLayer]:\n    \"\"\"\n    Hook to configure custom data layer.\n    \"\"\"\n\n    # We don't use wrap_user_function here because:\n    # 1. We don't need to support async here and;\n    # 2. We don't want to change the API for get_data_layer() to be async, everywhere (at this point).\n    config.code.data_layer = func\n    return func\n\n\ndef on_feedback(func: Callable) -> Callable:\n    \"\"\"\n    Hook to react to user feedback events from the UI.\n    The decorated function is called every time feedback is received.\n\n    Args:\n        func (Callable[[Feedback], Any]): The function to be called when feedback is received. Takes a cl.Feedback object.\n\n    Example:\n        @cl.on_feedback\n        async def on_feedback(feedback: Feedback):\n            print(f\"Received feedback: {feedback.value} for step {feedback.forId}\")\n            # Handle feedback here\n\n    Returns:\n        Callable[[Feedback], Any]: The decorated on_feedback function.\n    \"\"\"\n    config.code.on_feedback = wrap_user_function(func)\n    return func\n\n\ndef on_slack_reaction_added(func: Callable[[Dict[str, Any]], Any]) -> Callable:\n    \"\"\"\n    Hook to react to Slack reaction_added events.\n    The decorated function is called every time a user adds a reaction to a message in Slack.\n\n    Args:\n        func (Callable[[Dict[str, Any]], Any]): The function to be called when a reaction is added.\n            Takes a Slack event dictionary containing:\n            - reaction: The emoji reaction name (e.g., \"thumbsup\")\n            - user: The user ID who added the reaction\n            - item: Dictionary with type, ts, and channel of the reacted item\n\n    Example:\n        @cl.on_slack_reaction_added\n        async def handle_reaction(event: Dict[str, Any]):\n            reaction = event.get(\"reaction\")\n            user_id = event.get(\"user\")\n            print(f\"User {user_id} added reaction {reaction}\")\n            # Handle reaction here\n\n    Returns:\n        Callable[[Dict[str, Any]], Any]: The decorated on_slack_reaction_added function.\n    \"\"\"\n    config.code.on_slack_reaction_added = wrap_user_function(func)\n    return func\n\n\ndef on_shared_thread_view(\n    func: Callable[[ThreadDict, Optional[User]], Awaitable[bool]],\n) -> Callable[[ThreadDict, Optional[User]], Awaitable[bool]]:\n    \"\"\"Hook to authorize viewing a shared thread.\n\n    Users must implement and return True to allow a non-author to view a thread.\n    Thread metadata contains \"is_shared\" boolean flag and \"shared_at\" timestamp for custom thread sharing.\n    Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool\n    \"\"\"\n    config.code.on_shared_thread_view = wrap_user_function(func)\n    return func\n"
  },
  {
    "path": "backend/chainlit/chat_context.py",
    "content": "from typing import TYPE_CHECKING, Dict, List\n\nfrom chainlit.context import context\n\nif TYPE_CHECKING:\n    from chainlit.message import Message\n\nchat_contexts: Dict[str, List[\"Message\"]] = {}\n\n\nclass ChatContext:\n    def get(self) -> List[\"Message\"]:\n        if not context.session:\n            return []\n\n        if context.session.id not in chat_contexts:\n            # Create a new chat context\n            chat_contexts[context.session.id] = []\n\n        return chat_contexts[context.session.id].copy()\n\n    def add(self, message: \"Message\"):\n        if not context.session:\n            return\n\n        if context.session.id not in chat_contexts:\n            chat_contexts[context.session.id] = []\n\n        if message not in chat_contexts[context.session.id]:\n            chat_contexts[context.session.id].append(message)\n\n        return message\n\n    def remove(self, message: \"Message\") -> bool:\n        if not context.session:\n            return False\n\n        if context.session.id not in chat_contexts:\n            return False\n\n        if message in chat_contexts[context.session.id]:\n            chat_contexts[context.session.id].remove(message)\n            return True\n\n        return False\n\n    def clear(self) -> None:\n        if context.session and context.session.id in chat_contexts:\n            chat_contexts[context.session.id] = []\n\n    def to_openai(self):\n        messages = []\n        for message in self.get():\n            if message.type == \"assistant_message\":\n                messages.append({\"role\": \"assistant\", \"content\": message.content})\n            elif message.type == \"user_message\":\n                messages.append({\"role\": \"user\", \"content\": message.content})\n            else:\n                messages.append({\"role\": \"system\", \"content\": message.content})\n\n        return messages\n\n\nchat_context = ChatContext()\n"
  },
  {
    "path": "backend/chainlit/chat_settings.py",
    "content": "from typing import Any, List\n\nfrom pydantic import Field\nfrom pydantic.dataclasses import dataclass\n\nfrom chainlit.context import context\nfrom chainlit.input_widget import InputWidget, Tab\n\n\n@dataclass\nclass ChatSettings:\n    \"\"\"Useful to create chat settings that the user can change.\"\"\"\n\n    inputs: List[InputWidget] | List[Tab] = Field(default_factory=list, exclude=True)\n\n    def __init__(\n        self,\n        inputs: List[InputWidget] | List[Tab],\n    ) -> None:\n        self.inputs = inputs\n\n    def settings(self):\n        def collect_settings(\n            values: dict[str, Any], inputs: List[InputWidget] | List[Tab]\n        ) -> None:\n            for input in inputs:\n                if isinstance(input, Tab):\n                    collect_settings(values, input.inputs)\n                else:\n                    values[input.id] = input.initial\n\n        settings: dict[str, Any] = {}\n        collect_settings(settings, self.inputs)\n        return settings\n\n    async def send(self):\n        settings = self.settings()\n        context.emitter.set_chat_settings(settings)\n\n        inputs_content = [input_widget.to_dict() for input_widget in self.inputs]\n        await context.emitter.emit(\"chat_settings\", inputs_content)\n\n        return settings\n"
  },
  {
    "path": "backend/chainlit/cli/__init__.py",
    "content": "import asyncio\nimport logging\nimport os\nimport sys\n\nimport click\nimport nest_asyncio\nimport uvicorn\n\n# Not sure if it is necessary to call nest_asyncio.apply() before the other imports\nnest_asyncio.apply()\n\n# ruff: noqa: E402\nfrom chainlit.auth import ensure_jwt_secret\nfrom chainlit.cache import init_lc_cache\nfrom chainlit.config import (\n    BACKEND_ROOT,\n    DEFAULT_HOST,\n    DEFAULT_PORT,\n    DEFAULT_ROOT_PATH,\n    config,\n    init_config,\n    lint_translations,\n    load_module,\n)\nfrom chainlit.logger import logger\nfrom chainlit.markdown import init_markdown\nfrom chainlit.secret import random_secret\nfrom chainlit.utils import check_file\n\nlogging.basicConfig(\n    level=logging.INFO,\n    stream=sys.stdout,\n    format=\"%(asctime)s - %(levelname)s - %(name)s - %(message)s\",\n    datefmt=\"%Y-%m-%d %H:%M:%S\",\n)\n\n\ndef assert_app():\n    if (\n        not config.code.on_chat_start\n        and not config.code.on_message\n        and not config.code.on_audio_chunk\n    ):\n        raise Exception(\n            \"You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback\"\n        )\n\n\n# Create the main command group for Chainlit CLI\n@click.group(context_settings={\"auto_envvar_prefix\": \"CHAINLIT\"})\n@click.version_option(prog_name=\"Chainlit\")\ndef cli():\n    return\n\n\n# Define the function to run Chainlit with provided options\ndef run_chainlit(target: str):\n    host = os.environ.get(\"CHAINLIT_HOST\", DEFAULT_HOST)\n    port = int(os.environ.get(\"CHAINLIT_PORT\", DEFAULT_PORT))\n    root_path = os.environ.get(\"CHAINLIT_ROOT_PATH\", DEFAULT_ROOT_PATH)\n\n    ssl_certfile = os.environ.get(\"CHAINLIT_SSL_CERT\", None)\n    ssl_keyfile = os.environ.get(\"CHAINLIT_SSL_KEY\", None)\n\n    ws_per_message_deflate_env = os.environ.get(\n        \"UVICORN_WS_PER_MESSAGE_DEFLATE\", \"true\"\n    )\n    ws_per_message_deflate = ws_per_message_deflate_env.lower() in [\n        \"true\",\n        \"1\",\n        \"yes\",\n    ]  # Convert to boolean\n\n    ws_protocol = os.environ.get(\"UVICORN_WS_PROTOCOL\", \"auto\")\n\n    config.run.host = host\n    config.run.port = port\n    config.run.root_path = root_path\n\n    from chainlit.server import app\n\n    check_file(target)\n    # Load the module provided by the user\n    config.run.module_name = target\n    load_module(config.run.module_name)\n\n    ensure_jwt_secret()\n    assert_app()\n\n    # Create the chainlit.md file if it doesn't exist\n    init_markdown(config.root)\n\n    # Initialize the LangChain cache if installed and enabled\n    init_lc_cache()\n\n    log_level = \"debug\" if config.run.debug else \"error\"\n\n    # Start the server\n    async def start():\n        config = uvicorn.Config(\n            app,\n            host=host,\n            port=port,\n            ws=ws_protocol,\n            log_level=log_level,\n            ws_per_message_deflate=ws_per_message_deflate,\n            ssl_keyfile=ssl_keyfile,\n            ssl_certfile=ssl_certfile,\n        )\n        server = uvicorn.Server(config)\n        await server.serve()\n\n    # Run the asyncio event loop instead of uvloop to enable re entrance\n    asyncio.run(start())\n    # uvicorn.run(app, host=host, port=port, log_level=log_level)\n\n\n# Define the \"run\" command for Chainlit CLI\n@cli.command(\"run\")\n@click.argument(\"target\", required=True, envvar=\"RUN_TARGET\")\n@click.option(\n    \"-w\",\n    \"--watch\",\n    default=False,\n    is_flag=True,\n    envvar=\"WATCH\",\n    help=\"Reload the app when the module changes\",\n)\n@click.option(\n    \"-h\",\n    \"--headless\",\n    default=False,\n    is_flag=True,\n    envvar=\"HEADLESS\",\n    help=\"Will prevent to auto open the app in the browser\",\n)\n@click.option(\n    \"-d\",\n    \"--debug\",\n    default=False,\n    is_flag=True,\n    envvar=\"DEBUG\",\n    help=\"Set the log level to debug\",\n)\n@click.option(\n    \"-c\",\n    \"--ci\",\n    default=False,\n    is_flag=True,\n    envvar=\"CI\",\n    help=\"Flag to run in CI mode\",\n)\n@click.option(\n    \"--no-cache\",\n    default=False,\n    is_flag=True,\n    envvar=\"NO_CACHE\",\n    help=\"Useful to disable third parties cache, such as langchain.\",\n)\n@click.option(\n    \"--ssl-cert\",\n    default=None,\n    envvar=\"CHAINLIT_SSL_CERT\",\n    help=\"Specify the file path for the SSL certificate.\",\n)\n@click.option(\n    \"--ssl-key\",\n    default=None,\n    envvar=\"CHAINLIT_SSL_KEY\",\n    help=\"Specify the file path for the SSL key\",\n)\n@click.option(\"--host\", help=\"Specify a different host to run the server on\")\n@click.option(\"--port\", help=\"Specify a different port to run the server on\")\n@click.option(\"--root-path\", help=\"Specify a different root path to run the server on\")\ndef chainlit_run(\n    target,\n    watch,\n    headless,\n    debug,\n    ci,\n    no_cache,\n    ssl_cert,\n    ssl_key,\n    host,\n    port,\n    root_path,\n):\n    if host:\n        os.environ[\"CHAINLIT_HOST\"] = host\n    if port:\n        os.environ[\"CHAINLIT_PORT\"] = port\n    if bool(ssl_cert) != bool(ssl_key):\n        raise click.UsageError(\n            \"Both --ssl-cert and --ssl-key must be provided together.\"\n        )\n    if ssl_cert:\n        os.environ[\"CHAINLIT_SSL_CERT\"] = ssl_cert\n        os.environ[\"CHAINLIT_SSL_KEY\"] = ssl_key\n    if root_path:\n        os.environ[\"CHAINLIT_ROOT_PATH\"] = root_path\n    if ci:\n        logger.info(\"Running in CI mode\")\n\n        no_cache = True\n        # This is required to have OpenAI LLM providers available for the CI run\n        os.environ[\"OPENAI_API_KEY\"] = \"sk-FAKE-OPENAI-API-KEY\"\n\n    config.run.headless = headless\n    config.run.debug = debug\n    config.run.no_cache = no_cache\n    config.run.ci = ci\n    config.run.watch = watch\n    config.run.ssl_cert = ssl_cert\n    config.run.ssl_key = ssl_key\n\n    run_chainlit(target)\n\n\n@cli.command(\"hello\")\n@click.argument(\"args\", nargs=-1)\ndef chainlit_hello(args=None, **kwargs):\n    hello_path = os.path.join(BACKEND_ROOT, \"sample\", \"hello.py\")\n    run_chainlit(hello_path)\n\n\n@cli.command(\"init\")\n@click.argument(\"args\", nargs=-1)\ndef chainlit_init(args=None, **kwargs):\n    init_config(log=True)\n\n\n@cli.command(\"create-secret\")\n@click.argument(\"args\", nargs=-1)\ndef chainlit_create_secret(args=None, **kwargs):\n    print(\n        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()}\"'\n    )\n\n\n@cli.command(\"lint-translations\")\n@click.argument(\"args\", nargs=-1)\ndef chainlit_lint_translations(args=None, **kwargs):\n    lint_translations()\n"
  },
  {
    "path": "backend/chainlit/config.py",
    "content": "import json\nimport os\nimport site\nimport sys\nfrom importlib import util\nfrom pathlib import Path\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Awaitable,\n    Callable,\n    Dict,\n    List,\n    Literal,\n    Optional,\n    Union,\n)\n\nimport tomli\nfrom pydantic import BaseModel, Field\nfrom pydantic_settings import BaseSettings\nfrom starlette.datastructures import Headers\n\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.logger import logger\nfrom chainlit.translations import lint_translation_json\nfrom chainlit.version import __version__\n\nfrom ._utils import is_path_inside\n\nif TYPE_CHECKING:\n    from fastapi import Request, Response\n\n    from chainlit.action import Action\n    from chainlit.message import Message\n    from chainlit.types import (\n        ChatProfile,\n        Feedback,\n        InputAudioChunk,\n        Starter,\n        StarterCategory,\n        ThreadDict,\n    )\n    from chainlit.user import User\nelse:\n    # Pydantic needs to resolve forward annotations. Because all of these are used\n    # within `typing.Callable`, alias to `Any` as Pydantic does not perform validation\n    # of callable argument/return types anyway.\n    Request = Response = Action = Message = ChatProfile = InputAudioChunk = Starter = StarterCategory = ThreadDict = User = Feedback = Any  # fmt: off\n\nBACKEND_ROOT = os.path.dirname(__file__)\nPACKAGE_ROOT = os.path.dirname(os.path.dirname(BACKEND_ROOT))\nTRANSLATIONS_DIR = os.path.join(BACKEND_ROOT, \"translations\")\n\n\n# Get the directory the script is running from\nAPP_ROOT = os.getenv(\"CHAINLIT_APP_ROOT\", os.getcwd())\n\n# Create the directory to store the uploaded files\nFILES_DIRECTORY = Path(APP_ROOT) / \".files\"\nFILES_DIRECTORY.mkdir(exist_ok=True)\n\nconfig_dir = os.path.join(APP_ROOT, \".chainlit\")\npublic_dir = os.path.join(APP_ROOT, \"public\")\nconfig_file = os.path.join(config_dir, \"config.toml\")\nconfig_translation_dir = os.path.join(config_dir, \"translations\")\n\n# Default config file created if none exists\nDEFAULT_CONFIG_STR = f\"\"\"[project]\n# List of environment variables to be provided by each user to use the app.\nuser_env = []\n\n# Duration (in seconds) during which the session is saved when the connection is lost\nsession_timeout = 3600\n\n# Duration (in seconds) of the user session expiry\nuser_session_timeout = 1296000  # 15 days\n\n# Enable third parties caching (e.g., LangChain cache)\ncache = false\n\n# Whether to persist user environment variables (API keys) to the database\n# Set to true to store user env vars in DB, false to exclude them for security\npersist_user_env = false\n\n# Whether to mask user environment variables (API keys) in the UI with password type\n# Set to true to show API keys as ***, false to show them as plain text\nmask_user_env = false\n\n# Authorized origins\nallow_origins = [\"*\"]\n\n[features]\n# 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)\nunsafe_allow_html = false\n\n# Process and display mathematical expressions. This can clash with \"$\" characters in messages.\nlatex = false\n\n# Enable rendering of user messages markdown\nuser_message_markdown = true\n\n# Autoscroll new user messages at the top of the window\nuser_message_autoscroll = true\n\n# Autoscroll new assistant messages\nassistant_message_autoscroll = true\n\n# Automatically tag threads with the current chat profile (if a chat profile is used)\nauto_tag_thread = true\n\n# Allow users to edit their own messages\nedit_message = true\n\n# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback.\nallow_thread_sharing = false\n\n# Enable favorite messages\nfavorites = false\n\n[features.slack]\n# Add emoji reaction when message is received (requires reactions:write OAuth scope)\nreaction_on_message_received = false\n\n# Authorize users to spontaneously upload files with messages\n[features.spontaneous_file_upload]\n    enabled = true\n    # Define accepted file types using MIME types\n    # Examples:\n    # 1. For specific file types:\n    #    accept = [\"image/jpeg\", \"image/png\", \"application/pdf\"]\n    # 2. For all files of certain type:\n    #    accept = [\"image/*\", \"audio/*\", \"video/*\"]\n    # 3. For specific file extensions:\n    #    accept = {{ \"application/octet-stream\" = [\".xyz\", \".pdb\"] }}\n    # Note: Using \"*/*\" is not recommended as it may cause browser warnings\n    accept = [\"*/*\"]\n    max_files = 20\n    max_size_mb = 500\n\n[features.audio]\n    # Enable audio features\n    enabled = false\n    # Sample rate of the audio\n    sample_rate = 24000\n\n[features.mcp]\n    # Enable Model Context Protocol (MCP) features\n    enabled = false\n\n[features.mcp.sse]\n    enabled = true\n\n[features.mcp.streamable-http]\n    enabled = true\n\n[features.mcp.stdio]\n    enabled = true\n    # Only the executables in the allow list can be used for MCP stdio server.\n    # Only need the base name of the executable, e.g. \"npx\", not \"/usr/bin/npx\".\n    # Please don't comment this line for now, we need it to parse the executable name.\n    allowed_executables = [ \"npx\", \"uvx\" ]\n\n[UI]\n# Name of the assistant.\nname = \"Assistant\"\n\n# default_theme = \"dark\"\n\n# Force a specific language for all users (e.g., \"en-US\", \"he-IL\", \"fr-FR\")\n# If not set, the browser's language will be used\n# language = \"en-US\"\n\n# layout = \"wide\"\n\n# default_sidebar_state = \"open\"  # Options: \"open\", \"closed\", \"hidden\"\n\n# Chat settings display location: \"message_composer\" (default) or \"sidebar\" (header)\n# chat_settings_location = \"message_composer\"\n\n# Default state of chat settings sidebar when location is \"sidebar\"\n# default_chat_settings_open = false\n\n# Whether to prompt user confirmation on clicking 'New Chat'\nconfirm_new_chat = true\n\n# Description of the assistant. This is used for HTML tags.\n# description = \"\"\n\n# Chain of Thought (CoT) display mode. Can be \"hidden\", \"tool_call\" or \"full\".\ncot = \"full\"\n\n# Specify a CSS file that can be used to customize the user interface.\n# The CSS file can be served from the public directory or via an external link.\n# custom_css = \"/public/test.css\"\n\n# Specify additional attributes for a custom CSS file\n# custom_css_attributes = \"media=\\\\\\\"print\\\\\\\"\"\n\n# Specify a JavaScript file that can be used to customize the user interface.\n# The JavaScript file can be served from the public directory.\n# custom_js = \"/public/test.js\"\n\n# The style of alert boxes. Can be \"classic\" or \"modern\".\nalert_style = \"classic\"\n\n# Specify additional attributes for custom JS file\n# custom_js_attributes = \"async type = \\\\\\\"module\\\\\\\"\"\n\n# Custom login page image, relative to public directory or external URL\n# login_page_image = \"/public/custom-background.jpg\"\n\n# Custom login page image filter (Tailwind internal filters, no dark/light variants)\n# login_page_image_filter = \"brightness-50 grayscale\"\n# login_page_image_dark_filter = \"contrast-200 blur-sm\"\n\n# Specify a custom meta URL (used for meta tags like og:url)\n# custom_meta_url = \"https://github.com/Chainlit/chainlit\"\n\n# Specify a custom meta image url.\n# custom_meta_image_url = \"https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png\"\n\n# Load assistant logo directly from URL.\nlogo_file_url = \"\"\n\n# Load assistant avatar image directly from URL.\ndefault_avatar_file_url = \"\"\n\n# Avatar size in pixels (default: 20).\n# avatar_size = 20\n\n# Specify a custom build directory for the frontend.\n# This can be used to customize the frontend code.\n# Be careful: If this is a relative path, it should not start with a slash.\n# custom_build = \"./public/build\"\n\n# Specify optional one or more custom links in the header.\n# [[UI.header_links]]\n#     name = \"Issues\"\n#     display_name = \"Report Issue\"\n#     icon_url = \"https://avatars.githubusercontent.com/u/128686189?s=200&v=4\"\n#     url = \"https://github.com/Chainlit/chainlit/issues\"\n#     target = \"_blank\" (default)  # Optional: \"_self\", \"_parent\", \"_top\".\n\n[meta]\ngenerated_by = \"{__version__}\"\n\"\"\"\n\n\nDEFAULT_HOST = \"127.0.0.1\"\nDEFAULT_PORT = 8000\nDEFAULT_ROOT_PATH = \"\"\n\n\nclass RunSettings(BaseModel):\n    # Name of the module (python file) used in the run command\n    module_name: Optional[str] = None\n    host: str = DEFAULT_HOST\n    port: int = DEFAULT_PORT\n    ssl_cert: Optional[str] = None\n    ssl_key: Optional[str] = None\n    root_path: str = DEFAULT_ROOT_PATH\n    headless: bool = False\n    watch: bool = False\n    no_cache: bool = False\n    debug: bool = False\n    ci: bool = False\n\n\nclass PaletteOptions(BaseModel):\n    main: Optional[str] = \"\"\n    light: Optional[str] = \"\"\n    dark: Optional[str] = \"\"\n\n\nclass TextOptions(BaseModel):\n    primary: Optional[str] = \"\"\n    secondary: Optional[str] = \"\"\n\n\nclass Palette(BaseModel):\n    primary: Optional[PaletteOptions] = None\n    background: Optional[str] = \"\"\n    paper: Optional[str] = \"\"\n    text: Optional[TextOptions] = None\n\n\nclass SpontaneousFileUploadFeature(BaseModel):\n    enabled: Optional[bool] = None\n    accept: Optional[Union[List[str], Dict[str, List[str]]]] = None\n    max_files: Optional[int] = None\n    max_size_mb: Optional[int] = None\n\n\nclass AudioFeature(BaseModel):\n    sample_rate: int = 24000\n    enabled: bool = False\n\n\nclass McpSseFeature(BaseModel):\n    enabled: bool = True\n\n\nclass McpStreamableHttpFeature(BaseModel):\n    enabled: bool = True\n\n\nclass McpStdioFeature(BaseModel):\n    enabled: bool = True\n    allowed_executables: Optional[list[str]] = None\n\n\nclass SlackFeature(BaseModel):\n    reaction_on_message_received: bool = False\n\n\nclass McpFeature(BaseModel):\n    enabled: bool = False\n    sse: McpSseFeature = Field(default_factory=McpSseFeature)\n    streamable_http: McpStreamableHttpFeature = Field(\n        default_factory=McpStreamableHttpFeature\n    )\n    stdio: McpStdioFeature = Field(default_factory=McpStdioFeature)\n\n\nclass FeaturesSettings(BaseModel):\n    spontaneous_file_upload: Optional[SpontaneousFileUploadFeature] = None\n    audio: Optional[AudioFeature] = Field(default_factory=AudioFeature)\n    mcp: McpFeature = Field(default_factory=McpFeature)\n    slack: SlackFeature = Field(default_factory=SlackFeature)\n    latex: bool = False\n    user_message_markdown: bool = True\n    user_message_autoscroll: bool = True\n    assistant_message_autoscroll: bool = True\n    unsafe_allow_html: bool = False\n    auto_tag_thread: bool = True\n    edit_message: bool = True\n    allow_thread_sharing: bool = False\n    favorites: bool = False\n\n\nclass HeaderLink(BaseModel):\n    name: str\n    icon_url: str\n    url: str\n    display_name: Optional[str] = None\n    target: Optional[Literal[\"_blank\", \"_self\", \"_parent\", \"_top\"]] = None\n\n\nclass UISettings(BaseModel):\n    name: str\n    description: str = \"\"\n    cot: Literal[\"hidden\", \"tool_call\", \"full\"] = \"full\"\n    default_theme: Optional[Literal[\"light\", \"dark\"]] = \"dark\"\n    language: Optional[str] = None\n    layout: Optional[Literal[\"default\", \"wide\"]] = \"default\"\n    default_sidebar_state: Optional[Literal[\"open\", \"closed\", \"hidden\"]] = \"open\"\n    chat_settings_location: Optional[Literal[\"message_composer\", \"sidebar\"]] = (\n        \"message_composer\"\n    )\n    default_chat_settings_open: bool = False\n    confirm_new_chat: bool = True\n    github: Optional[str] = None\n    custom_css: Optional[str] = None\n    custom_css_attributes: Optional[str] = \"\"\n    custom_js: Optional[str] = None\n\n    alert_style: Optional[Literal[\"classic\", \"modern\"]] = \"classic\"\n    custom_js_attributes: Optional[str] = \"defer\"\n    login_page_image: Optional[str] = None\n    login_page_image_filter: Optional[str] = None\n    login_page_image_dark_filter: Optional[str] = None\n\n    custom_meta_url: Optional[str] = None\n    custom_meta_image_url: Optional[str] = None\n    logo_file_url: Optional[str] = None\n    default_avatar_file_url: Optional[str] = None\n    avatar_size: Optional[int] = None\n    custom_build: Optional[str] = None\n    header_links: Optional[List[HeaderLink]] = None\n\n\nclass CodeSettings(BaseModel):\n    # App action functions\n    action_callbacks: Dict[str, Callable[[\"Action\"], Any]]\n\n    # Module object loaded from the module_name\n    module: Any = None\n\n    # App life cycle callbacks\n    on_app_startup: Optional[Callable[[], Union[None, Awaitable[None]]]] = None\n    on_app_shutdown: Optional[Callable[[], Union[None, Awaitable[None]]]] = None\n\n    # Session life cycle callbacks\n    on_logout: Optional[Callable[[\"Request\", \"Response\"], Any]] = None\n    on_stop: Optional[Callable[[], Any]] = None\n    on_chat_start: Optional[Callable[[], Any]] = None\n    on_chat_end: Optional[Callable[[], Any]] = None\n    on_chat_resume: Optional[Callable[[\"ThreadDict\"], Any]] = None\n    on_message: Optional[Callable[[\"Message\"], Any]] = None\n    on_feedback: Optional[Callable[[\"Feedback\"], Any]] = None\n    on_slack_reaction_added: Optional[Callable[[Dict[str, Any]], Any]] = None\n    on_audio_start: Optional[Callable[[], Any]] = None\n    on_audio_chunk: Optional[Callable[[\"InputAudioChunk\"], Any]] = None\n    on_audio_end: Optional[Callable[[], Any]] = None\n    on_mcp_connect: Optional[Callable] = None\n    on_mcp_disconnect: Optional[Callable] = None\n    on_settings_edit: Optional[Callable[[Dict[str, Any]], Any]] = None\n    on_settings_update: Optional[Callable[[Dict[str, Any]], Any]] = None\n    set_chat_profiles: Optional[\n        Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"ChatProfile\"]]]\n    ] = None\n    set_starters: Optional[\n        Callable[[Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"Starter\"]]]\n    ] = None\n    set_starter_categories: Optional[\n        Callable[\n            [Optional[\"User\"], Optional[\"str\"]], Awaitable[List[\"StarterCategory\"]]\n        ]\n    ] = None\n    on_shared_thread_view: Optional[\n        Callable[[\"ThreadDict\", Optional[\"User\"]], Awaitable[bool]]\n    ] = None\n    # Auth callbacks\n    password_auth_callback: Optional[\n        Callable[[str, str], Awaitable[Optional[\"User\"]]]\n    ] = None\n    header_auth_callback: Optional[Callable[[Headers], Awaitable[Optional[\"User\"]]]] = (\n        None\n    )\n    oauth_callback: Optional[\n        Callable[[str, str, Dict[str, str], \"User\"], Awaitable[Optional[\"User\"]]]\n    ] = None\n\n    # Helpers\n    on_window_message: Optional[Callable[[str], Any]] = None\n    author_rename: Optional[Callable[[str], Awaitable[str]]] = None\n    data_layer: Optional[Callable[[], BaseDataLayer]] = None\n\n\nclass ProjectSettings(BaseModel):\n    allow_origins: List[str] = Field(default_factory=lambda: [\"*\"])\n    # Socket.io client transports option\n    transports: Optional[List[str]] = None\n    # 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.\n    user_env: Optional[List[str]] = None\n    # Path to the local langchain cache database\n    lc_cache_path: Optional[str] = None\n    # Path to the local chat db\n    # Duration (in seconds) during which the session is saved when the connection is lost\n    session_timeout: int = 300\n    # Duration (in seconds) of the user session expiry\n    user_session_timeout: int = 1296000  # 15 days\n    # Enable third parties caching (e.g LangChain cache)\n    cache: bool = False\n    # Whether to persist user environment variables (API keys) to the database\n    persist_user_env: Optional[bool] = False\n    # Whether to mask user environment variables (API keys) in the UI with password type\n    mask_user_env: Optional[bool] = False\n\n\nclass ChainlitConfigOverrides(BaseModel):\n    \"\"\"Configuration overrides that can be applied to specific chat profiles.\"\"\"\n\n    ui: Optional[UISettings] = None\n    features: Optional[FeaturesSettings] = None\n    project: Optional[ProjectSettings] = None\n\n\nclass ChainlitConfig(BaseSettings):\n    root: str = APP_ROOT\n    chainlit_server: str = Field(default=\"\")\n    run: RunSettings = Field(default_factory=RunSettings)\n    features: FeaturesSettings\n    ui: UISettings\n    project: ProjectSettings\n    code: CodeSettings\n\n    def load_translation(self, language: str):\n        translation = {}\n        default_language = \"en-US\"\n        # fallback to root language (ex: `de` when `de-DE` is not found)\n        parent_language = language.split(\"-\")[0]\n\n        translation_dir = Path(config_translation_dir)\n\n        translation_lib_file_path = translation_dir / f\"{language}.json\"\n        translation_lib_parent_language_file_path = (\n            translation_dir / f\"{parent_language}.json\"\n        )\n        default_translation_lib_file_path = translation_dir / f\"{default_language}.json\"\n\n        if (\n            is_path_inside(translation_lib_file_path, translation_dir)\n            and translation_lib_file_path.is_file()\n        ):\n            translation = json.loads(\n                translation_lib_file_path.read_text(encoding=\"utf-8\")\n            )\n        elif (\n            is_path_inside(translation_lib_parent_language_file_path, translation_dir)\n            and translation_lib_parent_language_file_path.is_file()\n        ):\n            logger.warning(\n                f\"Translation file for {language} not found. Using parent translation {parent_language}.\"\n            )\n            translation = json.loads(\n                translation_lib_parent_language_file_path.read_text(encoding=\"utf-8\")\n            )\n        elif (\n            is_path_inside(default_translation_lib_file_path, translation_dir)\n            and default_translation_lib_file_path.is_file()\n        ):\n            logger.warning(\n                f\"Translation file for {language} not found. Using default translation {default_language}.\"\n            )\n            translation = json.loads(\n                default_translation_lib_file_path.read_text(encoding=\"utf-8\")\n            )\n\n        return translation\n\n    def with_overrides(\n        self, overrides: \"ChainlitConfigOverrides | None\"\n    ) -> \"ChainlitConfig\":\n        base = self.model_dump()\n        patch = overrides.model_dump(exclude_unset=True) if overrides else {}\n\n        def _merge(a, b):\n            if isinstance(a, dict) and isinstance(b, dict):\n                out = dict(a)\n                for k, v in b.items():\n                    out[k] = _merge(out.get(k), v)\n                return out\n            return b\n\n        merged = _merge(base, patch) if patch else base\n        return type(self).model_validate(merged)\n\n\ndef init_config(log: bool = False):\n    \"\"\"Initialize the configuration file if it doesn't exist.\"\"\"\n    if not os.path.exists(config_file):\n        os.makedirs(config_dir, exist_ok=True)\n        with open(config_file, \"w\", encoding=\"utf-8\") as f:\n            f.write(DEFAULT_CONFIG_STR)\n            logger.info(f\"Created default config file at {config_file}\")\n    elif log:\n        logger.info(f\"Config file already exists at {config_file}\")\n\n    if not os.path.exists(config_translation_dir):\n        os.makedirs(config_translation_dir, exist_ok=True)\n        logger.info(\n            f\"Created default translation directory at {config_translation_dir}\"\n        )\n\n    for file in os.listdir(TRANSLATIONS_DIR):\n        if file.endswith(\".json\"):\n            dst = os.path.join(config_translation_dir, file)\n            if not os.path.exists(dst):\n                src = os.path.join(TRANSLATIONS_DIR, file)\n                with open(src, encoding=\"utf-8\") as f:\n                    translation = json.load(f)\n                    with open(dst, \"w\", encoding=\"utf-8\") as f:\n                        json.dump(translation, f, indent=4)\n                        logger.info(f\"Created default translation file at {dst}\")\n\n\ndef load_module(target: str, force_refresh: bool = False):\n    \"\"\"Load the specified module.\"\"\"\n\n    # Get the target's directory\n    target_dir = os.path.dirname(os.path.abspath(target))\n\n    # Add the target's directory to the Python path\n    sys.path.insert(0, target_dir)\n\n    if force_refresh:\n        # Get current site packages dirs\n        site_package_dirs = site.getsitepackages()\n\n        # Clear the modules related to the app from sys.modules\n        for module_name, module in list(sys.modules.items()):\n            if (\n                hasattr(module, \"__file__\")\n                and module.__file__\n                and module.__file__.startswith(target_dir)\n                and not any(module.__file__.startswith(p) for p in site_package_dirs)\n            ):\n                sys.modules.pop(module_name, None)\n\n    spec = util.spec_from_file_location(target, target)\n    if not spec or not spec.loader:\n        sys.path.pop(0)\n        return\n\n    module = util.module_from_spec(spec)\n    if not module:\n        sys.path.pop(0)\n        return\n\n    spec.loader.exec_module(module)\n\n    sys.modules[target] = module\n\n    # Remove the target's directory from the Python path\n    sys.path.pop(0)\n\n\ndef load_settings():\n    with open(config_file, \"rb\") as f:\n        toml_dict = tomli.load(f)\n        # Load project settings\n        project_config = toml_dict.get(\"project\", {})\n        features_settings = toml_dict.get(\"features\", {})\n        ui_settings = toml_dict.get(\"UI\", {})\n        meta = toml_dict.get(\"meta\")\n\n        if not meta or meta.get(\"generated_by\") <= \"0.3.0\":\n            raise ValueError(\n                f\"Your config file '{config_file}' is outdated. Please delete it and restart the app to regenerate it.\"\n            )\n\n        lc_cache_path = os.path.join(config_dir, \".langchain.db\")\n\n        project_settings = ProjectSettings(\n            lc_cache_path=lc_cache_path,\n            **project_config,\n        )\n\n        features_settings = FeaturesSettings(**features_settings)\n\n        ui_settings = UISettings(**ui_settings)\n\n        code_settings = CodeSettings(action_callbacks={})\n\n        return {\n            \"features\": features_settings,\n            \"ui\": ui_settings,\n            \"project\": project_settings,\n            \"code\": code_settings,\n        }\n\n\ndef reload_config():\n    \"\"\"Reload the configuration from the config file.\"\"\"\n    global config\n    if config is None:\n        return\n\n    # Preserve the module_name during config reload to ensure hot reload works\n    original_module_name = config.run.module_name if config.run else None\n\n    new_cfg = ChainlitConfig(**load_settings())\n    config.root = new_cfg.root\n    config.chainlit_server = new_cfg.chainlit_server\n    config.run = new_cfg.run\n    config.features = new_cfg.features\n    config.ui = new_cfg.ui\n\n    # Restore the preserved module_name\n    if original_module_name and config.run:\n        config.run.module_name = original_module_name\n    config.project = new_cfg.project\n    config.code = new_cfg.code\n\n\ndef load_config():\n    \"\"\"Load the configuration from the config file.\"\"\"\n    init_config()\n    settings = load_settings()\n    return ChainlitConfig(**settings)\n\n\ndef lint_translations():\n    # Load the ground truth (en-US.json file from chainlit source code)\n    src = os.path.join(TRANSLATIONS_DIR, \"en-US.json\")\n    with open(src, encoding=\"utf-8\") as f:\n        truth = json.load(f)\n\n        # Find the local app translations\n        for file in os.listdir(config_translation_dir):\n            if file.endswith(\".json\"):\n                # Load the translation file\n                to_lint = os.path.join(config_translation_dir, file)\n                with open(to_lint, encoding=\"utf-8\") as f2:\n                    translation = json.load(f2)\n\n                    # Lint the translation file\n                    lint_translation_json(file, truth, translation)\n\n\nconfig = load_config()\n"
  },
  {
    "path": "backend/chainlit/context.py",
    "content": "import asyncio\nimport uuid\nfrom contextvars import ContextVar\nfrom typing import TYPE_CHECKING, Dict, List, Optional, Union\n\nfrom lazify import LazyProxy\n\nfrom chainlit.session import ClientType, HTTPSession, WebsocketSession\n\nif TYPE_CHECKING:\n    from chainlit.emitter import BaseChainlitEmitter\n    from chainlit.step import Step\n    from chainlit.user import PersistedUser, User\n\nCL_RUN_NAMES = [\"on_chat_start\", \"on_message\", \"on_audio_end\"]\n\n\nclass ChainlitContextException(Exception):\n    def __init__(self, msg=\"Chainlit context not found\", *args, **kwargs):\n        super().__init__(msg, *args, **kwargs)\n\n\nclass ChainlitContext:\n    loop: asyncio.AbstractEventLoop\n    emitter: \"BaseChainlitEmitter\"\n    session: Union[\"HTTPSession\", \"WebsocketSession\"]\n\n    @property\n    def current_step(self):\n        if previous_steps := local_steps.get():\n            return previous_steps[-1]\n\n    @property\n    def current_run(self):\n        if previous_steps := local_steps.get():\n            return next(\n                (step for step in previous_steps if step.name in CL_RUN_NAMES), None\n            )\n\n    def __init__(\n        self,\n        session: Union[\"HTTPSession\", \"WebsocketSession\"],\n        emitter: Optional[\"BaseChainlitEmitter\"] = None,\n    ):\n        from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter\n\n        self.loop = asyncio.get_running_loop()\n        self.session = session\n\n        if emitter:\n            self.emitter = emitter\n        elif isinstance(self.session, HTTPSession):\n            self.emitter = BaseChainlitEmitter(self.session)\n        elif isinstance(self.session, WebsocketSession):\n            self.emitter = ChainlitEmitter(self.session)\n\n\ncontext_var: ContextVar[ChainlitContext] = ContextVar(\"chainlit\")\nlocal_steps: ContextVar[Optional[List[\"Step\"]]] = ContextVar(\n    \"local_steps\", default=None\n)\n\n\ndef init_ws_context(session_or_sid: Union[WebsocketSession, str]) -> ChainlitContext:\n    if not isinstance(session_or_sid, WebsocketSession):\n        session = WebsocketSession.require(session_or_sid)\n    else:\n        session = session_or_sid\n    context = ChainlitContext(session)\n    context_var.set(context)\n    return context\n\n\ndef init_http_context(\n    thread_id: Optional[str] = None,\n    user: Optional[Union[\"User\", \"PersistedUser\"]] = None,\n    auth_token: Optional[str] = None,\n    user_env: Optional[Dict[str, str]] = None,\n    client_type: ClientType = \"webapp\",\n) -> ChainlitContext:\n    from chainlit.data import get_data_layer\n\n    session_id = str(uuid.uuid4())\n    thread_id = thread_id or str(uuid.uuid4())\n    session = HTTPSession(\n        id=session_id,\n        thread_id=thread_id,\n        token=auth_token,\n        user=user,\n        client_type=client_type,\n        user_env=user_env,\n    )\n    context = ChainlitContext(session)\n    context_var.set(context)\n\n    if data_layer := get_data_layer():\n        if user_id := getattr(user, \"id\", None):\n            asyncio.create_task(\n                data_layer.update_thread(thread_id=thread_id, user_id=user_id)\n            )\n\n    return context\n\n\ndef get_context() -> ChainlitContext:\n    try:\n        return context_var.get()\n    except LookupError as e:\n        raise ChainlitContextException from e\n\n\ncontext: ChainlitContext = LazyProxy(get_context, enable_cache=False)\n"
  },
  {
    "path": "backend/chainlit/data/__init__.py",
    "content": "import os\nimport warnings\nfrom typing import Optional\n\nfrom .base import BaseDataLayer\nfrom .utils import (\n    queue_until_user_message as queue_until_user_message,  # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU.\n)\n\n_data_layer: Optional[BaseDataLayer] = None\n_data_layer_initialized = False\n\n\ndef get_data_layer():\n    global _data_layer, _data_layer_initialized\n\n    if not _data_layer_initialized:\n        if _data_layer:\n            # Data layer manually set, warn user that this is deprecated.\n\n            warnings.warn(\n                \"Setting data layer manually is deprecated. Use @data_layer instead.\",\n                DeprecationWarning,\n            )\n\n        else:\n            from chainlit.config import config\n\n            if config.code.data_layer:\n                # When @data_layer is configured, call it to get data layer.\n                _data_layer = config.code.data_layer()\n            elif database_url := os.environ.get(\"DATABASE_URL\"):\n                from .chainlit_data_layer import ChainlitDataLayer\n\n                if os.environ.get(\"LITERAL_API_KEY\"):\n                    warnings.warn(\n                        \"Both LITERAL_API_KEY and DATABASE_URL specified. Ignoring Literal AI data layer and relying on data layer pointing to DATABASE_URL.\"\n                    )\n\n                bucket_name = os.environ.get(\"BUCKET_NAME\")\n\n                # AWS S3\n                aws_region = os.getenv(\"APP_AWS_REGION\")\n                aws_access_key = os.getenv(\"APP_AWS_ACCESS_KEY\")\n                aws_secret_key = os.getenv(\"APP_AWS_SECRET_KEY\")\n                dev_aws_endpoint = os.getenv(\"DEV_AWS_ENDPOINT\")\n                is_using_s3 = bool(aws_access_key and aws_secret_key and aws_region)\n\n                # Google Cloud Storage\n                gcs_project_id = os.getenv(\"APP_GCS_PROJECT_ID\")\n                gcs_client_email = os.getenv(\"APP_GCS_CLIENT_EMAIL\")\n                gcs_private_key = os.getenv(\"APP_GCS_PRIVATE_KEY\")\n                is_using_gcs = bool(gcs_project_id)\n\n                # Azure Storage\n                azure_storage_account = os.getenv(\"APP_AZURE_STORAGE_ACCOUNT\")\n                azure_storage_key = os.getenv(\"APP_AZURE_STORAGE_ACCESS_KEY\")\n                is_using_azure = bool(azure_storage_account and azure_storage_key)\n\n                storage_client = None\n\n                if sum([is_using_s3, is_using_gcs, is_using_azure]) > 1:\n                    warnings.warn(\n                        \"Multiple storage configurations detected. Please use only one.\"\n                    )\n                elif is_using_s3:\n                    from chainlit.data.storage_clients.s3 import S3StorageClient\n\n                    storage_client = S3StorageClient(\n                        bucket=bucket_name,\n                        region_name=aws_region,\n                        aws_access_key_id=aws_access_key,\n                        aws_secret_access_key=aws_secret_key,\n                        endpoint_url=dev_aws_endpoint,\n                    )\n                elif is_using_gcs:\n                    from chainlit.data.storage_clients.gcs import GCSStorageClient\n\n                    storage_client = GCSStorageClient(\n                        project_id=gcs_project_id,\n                        client_email=gcs_client_email,\n                        private_key=gcs_private_key,\n                        bucket_name=bucket_name,\n                    )\n                elif is_using_azure:\n                    from chainlit.data.storage_clients.azure_blob import (\n                        AzureBlobStorageClient,\n                    )\n\n                    storage_client = AzureBlobStorageClient(\n                        container_name=bucket_name,\n                        storage_account=azure_storage_account,\n                        storage_key=azure_storage_key,\n                    )\n\n                _data_layer = ChainlitDataLayer(\n                    database_url=database_url, storage_client=storage_client\n                )\n            elif api_key := os.environ.get(\"LITERAL_API_KEY\"):\n                # When LITERAL_API_KEY is defined, use Literal AI data layer\n                from .literalai import LiteralDataLayer\n\n                # support legacy LITERAL_SERVER variable as fallback\n                server = os.environ.get(\"LITERAL_API_URL\") or os.environ.get(\n                    \"LITERAL_SERVER\"\n                )\n                _data_layer = LiteralDataLayer(api_key=api_key, server=server)\n\n        _data_layer_initialized = True\n\n    return _data_layer\n"
  },
  {
    "path": "backend/chainlit/data/acl.py",
    "content": "from fastapi import HTTPException\n\nfrom chainlit.data import get_data_layer\n\n\nasync def is_thread_author(username: str, thread_id: str):\n    data_layer = get_data_layer()\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data layer not initialized\")\n\n    thread_author = await data_layer.get_thread_author(thread_id)\n\n    if not thread_author:\n        raise HTTPException(status_code=404, detail=\"Thread not found\")\n\n    if thread_author != username:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n    else:\n        return True\n"
  },
  {
    "path": "backend/chainlit/data/base.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import TYPE_CHECKING, Dict, List, Optional\n\nfrom chainlit.types import (\n    Feedback,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\n\nfrom .utils import queue_until_user_message\n\nif TYPE_CHECKING:\n    from chainlit.element import Element, ElementDict\n    from chainlit.step import StepDict\n    from chainlit.user import PersistedUser, User\n\n\nclass BaseDataLayer(ABC):\n    \"\"\"Base class for data persistence.\"\"\"\n\n    @abstractmethod\n    async def get_user(self, identifier: str) -> Optional[\"PersistedUser\"]:\n        pass\n\n    @abstractmethod\n    async def create_user(self, user: \"User\") -> Optional[\"PersistedUser\"]:\n        pass\n\n    @abstractmethod\n    async def delete_feedback(\n        self,\n        feedback_id: str,\n    ) -> bool:\n        pass\n\n    @abstractmethod\n    async def upsert_feedback(\n        self,\n        feedback: Feedback,\n    ) -> str:\n        pass\n\n    @queue_until_user_message()\n    @abstractmethod\n    async def create_element(self, element: \"Element\"):\n        pass\n\n    @abstractmethod\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        pass\n\n    @queue_until_user_message()\n    @abstractmethod\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        pass\n\n    @queue_until_user_message()\n    @abstractmethod\n    async def create_step(self, step_dict: \"StepDict\"):\n        pass\n\n    @queue_until_user_message()\n    @abstractmethod\n    async def update_step(self, step_dict: \"StepDict\"):\n        pass\n\n    @queue_until_user_message()\n    @abstractmethod\n    async def delete_step(self, step_id: str):\n        pass\n\n    @abstractmethod\n    async def get_thread_author(self, thread_id: str) -> str:\n        return \"\"\n\n    @abstractmethod\n    async def delete_thread(self, thread_id: str):\n        pass\n\n    @abstractmethod\n    async def list_threads(\n        self, pagination: \"Pagination\", filters: \"ThreadFilter\"\n    ) -> \"PaginatedResponse[ThreadDict]\":\n        pass\n\n    @abstractmethod\n    async def get_thread(self, thread_id: str) -> \"Optional[ThreadDict]\":\n        pass\n\n    @abstractmethod\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        pass\n\n    @abstractmethod\n    async def build_debug_url(self) -> str:\n        pass\n\n    @abstractmethod\n    async def close(self) -> None:\n        pass\n\n    @abstractmethod\n    async def get_favorite_steps(self, user_id: str) -> List[\"StepDict\"]:\n        pass\n\n    async def set_step_favorite(\n        self, step_dict: \"StepDict\", favorite: bool\n    ) -> \"StepDict\":\n        metadata = step_dict.get(\"metadata\") or {}\n        metadata[\"favorite\"] = favorite\n        step_dict[\"metadata\"] = metadata\n        await self.update_step(step_dict)\n        return step_dict\n"
  },
  {
    "path": "backend/chainlit/data/chainlit_data_layer.py",
    "content": "import json\nimport uuid\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Union\n\nimport aiofiles\nimport asyncpg  # type: ignore\n\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.data.utils import queue_until_user_message\nfrom chainlit.element import ElementDict\nfrom chainlit.logger import logger\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    Feedback,\n    FeedbackDict,\n    PageInfo,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\nfrom chainlit.user import PersistedUser, User\n\n# Import for runtime usage (isinstance checks)\ntry:\n    from chainlit.data.storage_clients.gcs import GCSStorageClient\nexcept ImportError:\n    GCSStorageClient = None  # type: ignore[assignment,misc]\n\nif TYPE_CHECKING:\n    from chainlit.data.storage_clients.gcs import GCSStorageClient\n    from chainlit.element import Element, ElementDict\n    from chainlit.step import StepDict\n\nISO_FORMAT = \"%Y-%m-%dT%H:%M:%S.%fZ\"\n\n\nclass ChainlitDataLayer(BaseDataLayer):\n    def __init__(\n        self,\n        database_url: str,\n        storage_client: Optional[BaseStorageClient] = None,\n        show_logger: bool = False,\n    ):\n        self.database_url = database_url\n        self.pool: Optional[asyncpg.Pool] = None\n        self.storage_client = storage_client\n        self.show_logger = show_logger\n\n    async def connect(self):\n        if not self.pool:\n            self.pool = await asyncpg.create_pool(self.database_url)\n\n    async def get_current_timestamp(self) -> datetime:\n        return datetime.now()\n\n    async def execute_query(\n        self, query: str, params: Union[Dict, None] = None\n    ) -> List[Dict[str, Any]]:\n        if not self.pool:\n            await self.connect()\n\n        try:\n            async with self.pool.acquire() as connection:  # type: ignore\n                try:\n                    if params:\n                        records = await connection.fetch(query, *params.values())\n                    else:\n                        records = await connection.fetch(query)\n                    return [dict(record) for record in records]\n                except Exception as e:\n                    logger.error(f\"Database error: {e!s}\")\n                    raise\n        except (\n            asyncpg.exceptions.ConnectionDoesNotExistError,\n            asyncpg.exceptions.InterfaceError,\n        ) as e:\n            # Handle connection issues by cleaning up and rethrowing\n            logger.error(f\"Connection error: {e!s}\")\n            await self.cleanup()\n            raise\n\n    async def get_user(self, identifier: str) -> Optional[PersistedUser]:\n        query = \"\"\"\n        SELECT * FROM \"User\"\n        WHERE identifier = $1\n        \"\"\"\n        result = await self.execute_query(query, {\"identifier\": identifier})\n        if not result or len(result) == 0:\n            return None\n        row = result[0]\n\n        return PersistedUser(\n            id=str(row.get(\"id\")),\n            identifier=str(row.get(\"identifier\")),\n            createdAt=row.get(\"createdAt\").isoformat(),  # type: ignore\n            metadata=json.loads(row.get(\"metadata\", \"{}\")),\n        )\n\n    async def create_user(self, user: User) -> Optional[PersistedUser]:\n        query = \"\"\"\n        INSERT INTO \"User\" (id, identifier, metadata, \"createdAt\", \"updatedAt\")\n        VALUES ($1, $2, $3, $4, $5)\n        ON CONFLICT (identifier) DO UPDATE\n        SET metadata = $3\n        RETURNING *\n        \"\"\"\n        now = await self.get_current_timestamp()\n        params = {\n            \"id\": str(uuid.uuid4()),\n            \"identifier\": user.identifier,\n            \"metadata\": json.dumps(user.metadata),\n            \"created_at\": now,\n            \"updated_at\": now,\n        }\n        result = await self.execute_query(query, params)\n        row = result[0]\n\n        return PersistedUser(\n            id=str(row.get(\"id\")),\n            identifier=str(row.get(\"identifier\")),\n            createdAt=row.get(\"createdAt\").isoformat(),  # type: ignore\n            metadata=json.loads(row.get(\"metadata\", \"{}\")),\n        )\n\n    async def delete_feedback(self, feedback_id: str) -> bool:\n        query = \"\"\"\n        DELETE FROM \"Feedback\" WHERE id = $1\n        \"\"\"\n        await self.execute_query(query, {\"feedback_id\": feedback_id})\n        return True\n\n    async def upsert_feedback(self, feedback: Feedback) -> str:\n        query = \"\"\"\n        INSERT INTO \"Feedback\" (id, \"stepId\", name, value, comment)\n        VALUES ($1, $2, $3, $4, $5)\n        ON CONFLICT (id) DO UPDATE\n        SET value = $4, comment = $5\n        RETURNING id\n        \"\"\"\n        feedback_id = feedback.id or str(uuid.uuid4())\n        params = {\n            \"id\": feedback_id,\n            \"step_id\": feedback.forId,\n            \"name\": \"user_feedback\",\n            \"value\": float(feedback.value),\n            \"comment\": feedback.comment,\n        }\n        results = await self.execute_query(query, params)\n        return str(results[0][\"id\"])\n\n    @queue_until_user_message()\n    async def create_element(self, element: \"Element\"):\n        if not element.for_id:\n            return\n\n        if element.thread_id:\n            query = 'SELECT id FROM \"Thread\" WHERE id = $1'\n            results = await self.execute_query(query, {\"thread_id\": element.thread_id})\n            if not results:\n                await self.update_thread(thread_id=element.thread_id)\n\n        if element.for_id:\n            query = 'SELECT id FROM \"Step\" WHERE id = $1'\n            results = await self.execute_query(query, {\"step_id\": element.for_id})\n            if not results:\n                await self.create_step(\n                    {\n                        \"id\": element.for_id,\n                        \"metadata\": {},\n                        \"type\": \"run\",\n                        \"start_time\": await self.get_current_timestamp(),\n                        \"end_time\": await self.get_current_timestamp(),\n                    }\n                )\n\n        # Handle file uploads only if storage_client is configured\n        path = None\n        if self.storage_client:\n            content: Optional[Union[bytes, str]] = None\n\n            if element.path:\n                async with aiofiles.open(element.path, \"rb\") as f:\n                    content = await f.read()\n            elif element.content:\n                content = element.content\n            elif not element.url:\n                raise ValueError(\"Element url, path or content must be provided\")\n\n            if content is not None:\n                if element.thread_id:\n                    path = f\"threads/{element.thread_id}/files/{element.id}\"\n                else:\n                    path = f\"files/{element.id}\"\n\n                content_disposition = (\n                    f'attachment; filename=\"{element.name}\"'\n                    if not (\n                        GCSStorageClient is not None\n                        and isinstance(self.storage_client, GCSStorageClient)\n                    )\n                    else None\n                )\n                await self.storage_client.upload_file(\n                    object_key=path,\n                    data=content,\n                    mime=element.mime or \"application/octet-stream\",\n                    overwrite=True,\n                    content_disposition=content_disposition,\n                )\n\n        else:\n            # Log warning only if element has file content that needs uploading\n            if element.path or element.url or element.content:\n                logger.warning(\n                    \"Data Layer: No storage client configured. \"\n                    \"File will not be uploaded.\"\n                )\n\n        # Always persist element metadata to database\n        query = \"\"\"\n        INSERT INTO \"Element\" (\n            id, \"threadId\", \"stepId\", metadata, mime, name, \"objectKey\", url,\n            \"chainlitKey\", display, size, language, page, props\n        ) VALUES (\n            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14\n        )\n        ON CONFLICT (id) DO UPDATE SET\n            props = EXCLUDED.props\n        \"\"\"\n        params = {\n            \"id\": element.id,\n            \"thread_id\": element.thread_id,\n            \"step_id\": element.for_id,\n            \"metadata\": json.dumps(\n                {\n                    \"size\": element.size,\n                    \"language\": element.language,\n                    \"display\": element.display,\n                    \"type\": element.type,\n                    \"page\": getattr(element, \"page\", None),\n                }\n            ),\n            \"mime\": element.mime,\n            \"name\": element.name,\n            \"object_key\": path,\n            \"url\": element.url,\n            \"chainlit_key\": element.chainlit_key,\n            \"display\": element.display,\n            \"size\": element.size,\n            \"language\": element.language,\n            \"page\": getattr(element, \"page\", None),\n            \"props\": json.dumps(getattr(element, \"props\", {})),\n        }\n        await self.execute_query(query, params)\n\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[ElementDict]:\n        query = \"\"\"\n        SELECT * FROM \"Element\"\n        WHERE id = $1 AND \"threadId\" = $2\n        \"\"\"\n        results = await self.execute_query(\n            query, {\"element_id\": element_id, \"thread_id\": thread_id}\n        )\n\n        if not results:\n            return None\n\n        row = results[0]\n        metadata = json.loads(row.get(\"metadata\", \"{}\"))\n\n        return ElementDict(\n            id=str(row[\"id\"]),\n            threadId=str(row[\"threadId\"]),\n            type=metadata.get(\"type\", \"file\"),\n            url=str(row[\"url\"]),\n            name=str(row[\"name\"]),\n            mime=str(row[\"mime\"]),\n            objectKey=str(row[\"objectKey\"]),\n            forId=str(row[\"stepId\"]),\n            chainlitKey=row.get(\"chainlitKey\"),\n            display=row[\"display\"],\n            size=row[\"size\"],\n            language=row[\"language\"],\n            page=row[\"page\"],\n            autoPlay=row.get(\"autoPlay\"),\n            playerConfig=row.get(\"playerConfig\"),\n            props=json.loads(row.get(\"props\", \"{}\")),\n        )\n\n    @queue_until_user_message()\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        query = \"\"\"\n        SELECT * FROM \"Element\"\n        WHERE id = $1\n        \"\"\"\n        elements = await self.execute_query(query, {\"id\": element_id})\n\n        if self.storage_client is not None and len(elements) > 0:\n            if elements[0][\"objectKey\"]:\n                await self.storage_client.delete_file(\n                    object_key=elements[0][\"objectKey\"]\n                )\n        query = \"\"\"\n        DELETE FROM \"Element\"\n        WHERE id = $1\n        \"\"\"\n        params = {\"id\": element_id}\n\n        if thread_id:\n            query += ' AND \"threadId\" = $2'\n            params[\"thread_id\"] = thread_id\n\n        await self.execute_query(query, params)\n\n    @queue_until_user_message()\n    async def create_step(self, step_dict: StepDict):\n        if step_dict.get(\"threadId\"):\n            thread_query = 'SELECT id FROM \"Thread\" WHERE id = $1'\n            thread_results = await self.execute_query(\n                thread_query, {\"thread_id\": step_dict[\"threadId\"]}\n            )\n            if not thread_results:\n                await self.update_thread(thread_id=step_dict[\"threadId\"])\n\n        if step_dict.get(\"parentId\"):\n            parent_query = 'SELECT id FROM \"Step\" WHERE id = $1'\n            parent_results = await self.execute_query(\n                parent_query, {\"parent_id\": step_dict[\"parentId\"]}\n            )\n            if not parent_results:\n                await self.create_step(\n                    {\n                        \"id\": step_dict[\"parentId\"],\n                        \"metadata\": {},\n                        \"type\": \"run\",\n                        \"createdAt\": step_dict.get(\"createdAt\"),\n                    }\n                )\n\n        query = \"\"\"\n        INSERT INTO \"Step\" (\n            id, \"threadId\", \"parentId\", input, metadata, name, output,\n            type, \"startTime\", \"endTime\", \"showInput\", \"isError\"\n        ) VALUES (\n            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12\n        )\n        ON CONFLICT (id) DO UPDATE SET\n            \"parentId\" = COALESCE(EXCLUDED.\"parentId\", \"Step\".\"parentId\"),\n            input = COALESCE(NULLIF(EXCLUDED.input, ''), \"Step\".input),\n            metadata = CASE\n                WHEN EXCLUDED.metadata <> '{}' THEN EXCLUDED.metadata\n                ELSE \"Step\".metadata\n            END,\n            name = COALESCE(EXCLUDED.name, \"Step\".name),\n            output = COALESCE(NULLIF(EXCLUDED.output, ''), \"Step\".output),\n            type = CASE\n                WHEN EXCLUDED.type = 'run' THEN \"Step\".type\n                ELSE EXCLUDED.type\n            END,\n            \"threadId\" = COALESCE(EXCLUDED.\"threadId\", \"Step\".\"threadId\"),\n            \"endTime\" = COALESCE(EXCLUDED.\"endTime\", \"Step\".\"endTime\"),\n            \"startTime\" = LEAST(EXCLUDED.\"startTime\", \"Step\".\"startTime\"),\n            \"showInput\" = COALESCE(EXCLUDED.\"showInput\", \"Step\".\"showInput\"),\n            \"isError\" = COALESCE(EXCLUDED.\"isError\", \"Step\".\"isError\")\n        \"\"\"\n\n        timestamp = await self.get_current_timestamp()\n        created_at = step_dict.get(\"createdAt\")\n        if created_at:\n            timestamp = datetime.strptime(created_at, ISO_FORMAT)\n\n        params = {\n            \"id\": step_dict[\"id\"],\n            \"thread_id\": step_dict.get(\"threadId\"),\n            \"parent_id\": step_dict.get(\"parentId\"),\n            \"input\": step_dict.get(\"input\"),\n            \"metadata\": json.dumps(step_dict.get(\"metadata\", {})),\n            \"name\": step_dict.get(\"name\"),\n            \"output\": step_dict.get(\"output\"),\n            \"type\": step_dict[\"type\"],\n            \"start_time\": timestamp,\n            \"end_time\": timestamp,\n            \"show_input\": str(step_dict.get(\"showInput\", \"json\")),\n            \"is_error\": step_dict.get(\"isError\", False),\n        }\n        await self.execute_query(query, params)\n\n    @queue_until_user_message()\n    async def update_step(self, step_dict: StepDict):\n        await self.create_step(step_dict)\n\n    @queue_until_user_message()\n    async def delete_step(self, step_id: str):\n        # Delete associated elements and feedbacks first\n        await self.execute_query(\n            'DELETE FROM \"Element\" WHERE \"stepId\" = $1', {\"step_id\": step_id}\n        )\n        await self.execute_query(\n            'DELETE FROM \"Feedback\" WHERE \"stepId\" = $1', {\"step_id\": step_id}\n        )\n        # Delete the step\n        await self.execute_query(\n            'DELETE FROM \"Step\" WHERE id = $1', {\"step_id\": step_id}\n        )\n\n    async def get_step(self, step_id: str) -> Optional[StepDict]:\n        # Get step and related feedback\n        query = \"\"\"\n        SELECT  s.*,\n                f.id feedback_id,\n                f.value feedback_value,\n                f.\"comment\" feedback_comment\n        FROM \"Step\" s left join \"Feedback\" f on s.id = f.\"stepId\"\n        WHERE s.id = $1\n        \"\"\"\n        result = await self.execute_query(query, {\"step_id\": step_id})\n        if not result:\n            return None\n        return self._convert_step_row_to_dict(result[0])\n\n    async def get_thread_author(self, thread_id: str) -> str:\n        query = \"\"\"\n        SELECT u.identifier\n        FROM \"Thread\" t\n        JOIN \"User\" u ON t.\"userId\" = u.id\n        WHERE t.id = $1\n        \"\"\"\n        results = await self.execute_query(query, {\"thread_id\": thread_id})\n        if not results:\n            raise ValueError(f\"Thread {thread_id} not found\")\n        return results[0][\"identifier\"]\n\n    async def delete_thread(self, thread_id: str):\n        elements_query = \"\"\"\n        SELECT * FROM \"Element\"\n        WHERE \"threadId\" = $1\n        \"\"\"\n        elements_results = await self.execute_query(\n            elements_query, {\"thread_id\": thread_id}\n        )\n\n        if self.storage_client is not None:\n            for elem in elements_results:\n                if elem[\"objectKey\"]:\n                    await self.storage_client.delete_file(object_key=elem[\"objectKey\"])\n\n        await self.execute_query(\n            'DELETE FROM \"Thread\" WHERE id = $1', {\"thread_id\": thread_id}\n        )\n\n    async def list_threads(\n        self, pagination: Pagination, filters: ThreadFilter\n    ) -> PaginatedResponse[ThreadDict]:\n        query = \"\"\"\n        SELECT\n            t.*,\n            u.identifier as user_identifier,\n            (SELECT COUNT(*) FROM \"Thread\" WHERE \"userId\" = t.\"userId\") as total\n        FROM \"Thread\" t\n        LEFT JOIN \"User\" u ON t.\"userId\" = u.id\n        WHERE t.\"deletedAt\" IS NULL\n        \"\"\"\n        params: Dict[str, Any] = {}\n        param_count = 1\n\n        if filters.search:\n            query += f\" AND t.name ILIKE ${param_count}\"\n            params[\"name\"] = f\"%{filters.search}%\"\n            param_count += 1\n\n        if filters.userId:\n            query += f' AND t.\"userId\" = ${param_count}'\n            params[\"user_id\"] = filters.userId\n            param_count += 1\n\n        if pagination.cursor:\n            query += f' AND t.\"updatedAt\" < (SELECT \"updatedAt\" FROM \"Thread\" WHERE id = ${param_count})'\n            params[\"cursor\"] = pagination.cursor\n            param_count += 1\n\n        query += f' ORDER BY t.\"updatedAt\" DESC LIMIT ${param_count}'\n        params[\"limit\"] = pagination.first + 1\n\n        results = await self.execute_query(query, params)\n        threads = results\n\n        has_next_page = len(threads) > pagination.first\n        if has_next_page:\n            threads = threads[:-1]\n\n        thread_dicts = []\n        for thread in threads:\n            thread_dict = ThreadDict(\n                id=str(thread[\"id\"]),\n                createdAt=thread[\"updatedAt\"].isoformat(),\n                name=thread[\"name\"],\n                userId=str(thread[\"userId\"]) if thread[\"userId\"] else None,\n                userIdentifier=thread[\"user_identifier\"],\n                metadata=json.loads(thread[\"metadata\"]),\n                steps=[],\n                elements=[],\n                tags=[],\n            )\n            thread_dicts.append(thread_dict)\n\n        return PaginatedResponse(\n            pageInfo=PageInfo(\n                hasNextPage=has_next_page,\n                startCursor=thread_dicts[0][\"id\"] if thread_dicts else None,\n                endCursor=thread_dicts[-1][\"id\"] if thread_dicts else None,\n            ),\n            data=thread_dicts,\n        )\n\n    async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:\n        query = \"\"\"\n        SELECT t.*, u.identifier as user_identifier\n        FROM \"Thread\" t\n        LEFT JOIN \"User\" u ON t.\"userId\" = u.id\n        WHERE t.id = $1 AND t.\"deletedAt\" IS NULL\n        \"\"\"\n        results = await self.execute_query(query, {\"thread_id\": thread_id})\n\n        if not results:\n            return None\n\n        thread = results[0]\n\n        # Get steps and related feedback\n        steps_query = \"\"\"\n        SELECT  s.*,\n                f.id feedback_id,\n                f.value feedback_value,\n                f.\"comment\" feedback_comment\n        FROM \"Step\" s left join \"Feedback\" f on s.id = f.\"stepId\"\n        WHERE s.\"threadId\" = $1\n        ORDER BY \"startTime\"\n        \"\"\"\n        steps_results = await self.execute_query(steps_query, {\"thread_id\": thread_id})\n\n        # Get elements\n        elements_query = \"\"\"\n        SELECT * FROM \"Element\"\n        WHERE \"threadId\" = $1\n        \"\"\"\n        elements_results = await self.execute_query(\n            elements_query, {\"thread_id\": thread_id}\n        )\n\n        if self.storage_client is not None:\n            for elem in elements_results:\n                if not elem[\"url\"] and elem[\"objectKey\"]:\n                    elem[\"url\"] = await self.storage_client.get_read_url(\n                        object_key=elem[\"objectKey\"],\n                    )\n\n        return ThreadDict(\n            id=str(thread[\"id\"]),\n            createdAt=thread[\"createdAt\"].isoformat(),\n            name=thread[\"name\"],\n            userId=str(thread[\"userId\"]) if thread[\"userId\"] else None,\n            userIdentifier=thread[\"user_identifier\"],\n            metadata=json.loads(thread[\"metadata\"]),\n            steps=[self._convert_step_row_to_dict(step) for step in steps_results],\n            elements=[\n                self._convert_element_row_to_dict(elem) for elem in elements_results\n            ],\n            tags=[],\n        )\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        if self.show_logger:\n            logger.info(f\"asyncpg: update_thread, thread_id={thread_id}\")\n\n        thread_name = truncate(\n            name\n            if name is not None\n            else (metadata.get(\"name\") if metadata and \"name\" in metadata else None)\n        )\n\n        # Merge incoming metadata with existing metadata, deleting incoming keys with None values\n        merged_metadata = None\n        if metadata is not None:\n            existing = await self.execute_query(\n                'SELECT \"metadata\" FROM \"Thread\" WHERE id = $1',\n                {\"thread_id\": thread_id},\n            )\n            base = {}\n            if isinstance(existing, list) and existing:\n                raw = existing[0].get(\"metadata\") or {}\n                if isinstance(raw, str):\n                    try:\n                        base = json.loads(raw)\n                    except json.JSONDecodeError:\n                        base = {}\n                elif isinstance(raw, dict):\n                    base = raw\n            to_delete = {k for k, v in metadata.items() if v is None}\n            incoming = {k: v for k, v in metadata.items() if v is not None}\n            base = {k: v for k, v in base.items() if k not in to_delete}\n            merged_metadata = {**base, **incoming}\n\n        data = {\n            \"id\": thread_id,\n            \"name\": thread_name,\n            \"userId\": user_id,\n            \"tags\": tags,\n            \"metadata\": json.dumps(merged_metadata)\n            if merged_metadata is not None\n            else None,\n            \"updatedAt\": datetime.now(),\n        }\n\n        # Remove None values\n        data = {k: v for k, v in data.items() if v is not None}\n\n        # Build the query dynamically based on available fields\n        columns = [f'\"{k}\"' for k in data.keys()]\n        placeholders = [f\"${i + 1}\" for i in range(len(data))]\n        values = list(data.values())\n\n        update_sets = [f'\"{k}\" = EXCLUDED.\"{k}\"' for k in data.keys() if k != \"id\"]\n\n        if update_sets:\n            query = f\"\"\"\n                INSERT INTO \"Thread\" ({\", \".join(columns)})\n                VALUES ({\", \".join(placeholders)})\n                ON CONFLICT (id) DO UPDATE\n                SET {\", \".join(update_sets)};\n            \"\"\"\n        else:\n            query = f\"\"\"\n                INSERT INTO \"Thread\" ({\", \".join(columns)})\n                VALUES ({\", \".join(placeholders)})\n                ON CONFLICT (id) DO NOTHING\n            \"\"\"\n\n        await self.execute_query(query, {str(i + 1): v for i, v in enumerate(values)})\n\n    async def get_favorite_steps(self, user_id: str) -> List[StepDict]:\n        query = \"\"\"\n                SELECT s.*\n                FROM \"Step\" s\n                         JOIN \"Thread\" t ON s.\"threadId\" = t.id\n                WHERE t.\"userId\" = $1\n                  AND s.metadata::jsonb->>'favorite' = 'true'\n                ORDER BY s.\"createdAt\" DESC \\\n                \"\"\"\n        results = await self.execute_query(query, {\"user_id\": user_id})\n        return [self._convert_step_row_to_dict(row) for row in results]\n\n    def _extract_feedback_dict_from_step_row(self, row: Dict) -> Optional[FeedbackDict]:\n        if row.get(\"feedback_id\", None) is not None:\n            return FeedbackDict(\n                forId=str(row[\"id\"]),\n                id=str(row[\"feedback_id\"]),\n                value=row[\"feedback_value\"],\n                comment=row[\"feedback_comment\"],\n            )\n        return None\n\n    def _convert_step_row_to_dict(self, row: Dict) -> StepDict:\n        return StepDict(\n            id=str(row[\"id\"]),\n            threadId=str(row[\"threadId\"]) if row.get(\"threadId\") else \"\",\n            parentId=str(row[\"parentId\"]) if row.get(\"parentId\") else None,\n            name=str(row.get(\"name\")),\n            type=row[\"type\"],\n            input=row.get(\"input\", {}),\n            output=row.get(\"output\", {}),\n            metadata=json.loads(row.get(\"metadata\", \"{}\")),\n            createdAt=row[\"createdAt\"].isoformat() if row.get(\"createdAt\") else None,\n            start=row[\"startTime\"].isoformat() if row.get(\"startTime\") else None,\n            showInput=row.get(\"showInput\"),\n            isError=row.get(\"isError\"),\n            end=row[\"endTime\"].isoformat() if row.get(\"endTime\") else None,\n            feedback=self._extract_feedback_dict_from_step_row(row),\n        )\n\n    def _convert_element_row_to_dict(self, row: Dict) -> ElementDict:\n        metadata = json.loads(row.get(\"metadata\", \"{}\"))\n        return ElementDict(\n            id=str(row[\"id\"]),\n            threadId=str(row[\"threadId\"]) if row.get(\"threadId\") else None,\n            type=metadata.get(\"type\", \"file\"),\n            url=row[\"url\"],\n            name=row[\"name\"],\n            mime=row[\"mime\"],\n            objectKey=row[\"objectKey\"],\n            forId=str(row[\"stepId\"]),\n            chainlitKey=row.get(\"chainlitKey\"),\n            display=row[\"display\"],\n            size=row[\"size\"],\n            language=row[\"language\"],\n            page=row[\"page\"],\n            autoPlay=row.get(\"autoPlay\"),\n            playerConfig=row.get(\"playerConfig\"),\n            props=json.loads(row.get(\"props\") or \"{}\"),\n        )\n\n    async def build_debug_url(self) -> str:\n        return \"\"\n\n    async def cleanup(self):\n        \"\"\"Cleanup database connections\"\"\"\n        if self.pool:\n            logger.debug(\"Cleaning up connection pool\")\n            await self.pool.close()\n            self.pool = None\n\n    async def close(self) -> None:\n        if self.storage_client:\n            await self.storage_client.close()\n        await self.cleanup()\n\n\ndef truncate(text: Optional[str], max_length: int = 255) -> Optional[str]:\n    return None if text is None else text[:max_length]\n"
  },
  {
    "path": "backend/chainlit/data/dynamodb.py",
    "content": "import asyncio\nimport json\nimport logging\nimport os\nimport random\nfrom dataclasses import asdict\nfrom datetime import datetime\nfrom decimal import Decimal\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast\n\nimport aiofiles\nimport aiohttp\nimport boto3  # type: ignore\nfrom boto3.dynamodb.types import TypeDeserializer, TypeSerializer\n\nfrom chainlit.context import context\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.data.utils import queue_until_user_message\nfrom chainlit.element import ElementDict\nfrom chainlit.logger import logger\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    Feedback,\n    PageInfo,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\nfrom chainlit.user import PersistedUser, User\n\nif TYPE_CHECKING:\n    from mypy_boto3_dynamodb import DynamoDBClient\n\n    from chainlit.element import Element\n\n\n_logger = logger.getChild(\"DynamoDB\")\n_logger.setLevel(logging.WARNING)\n\n\nclass DynamoDBDataLayer(BaseDataLayer):\n    def __init__(\n        self,\n        table_name: str,\n        client: Optional[\"DynamoDBClient\"] = None,\n        storage_provider: Optional[BaseStorageClient] = None,\n        user_thread_limit: int = 10,\n    ):\n        if client:\n            self.client = client\n        else:\n            region_name = os.environ.get(\"AWS_REGION\", \"us-east-1\")\n            self.client = boto3.client(\"dynamodb\", region_name=region_name)  # type: ignore\n\n        self.table_name = table_name\n        self.storage_provider = storage_provider\n        self.user_thread_limit = user_thread_limit\n\n        self._type_deserializer = TypeDeserializer()\n        self._type_serializer = TypeSerializer()\n\n    def _get_current_timestamp(self) -> str:\n        return datetime.now().isoformat() + \"Z\"\n\n    def _serialize_item(self, item: dict[str, Any]) -> dict[str, Any]:\n        def convert_floats(obj):\n            if isinstance(obj, float):\n                return Decimal(str(obj))\n            elif isinstance(obj, dict):\n                return {k: convert_floats(v) for k, v in obj.items()}\n            elif isinstance(obj, list):\n                return [convert_floats(v) for v in obj]\n            else:\n                return obj\n\n        return {\n            key: self._type_serializer.serialize(convert_floats(value))\n            for key, value in item.items()\n        }\n\n    def _deserialize_item(self, item: dict[str, Any]) -> dict[str, Any]:\n        def convert_decimals(obj):\n            if isinstance(obj, Decimal):\n                return float(obj)\n            elif isinstance(obj, dict):\n                return {k: convert_decimals(v) for k, v in obj.items()}\n            elif isinstance(obj, list):\n                return [convert_decimals(v) for v in obj]\n            else:\n                return obj\n\n        return {\n            key: convert_decimals(self._type_deserializer.deserialize(value))\n            for key, value in item.items()\n        }\n\n    def _update_item(self, key: Dict[str, Any], updates: Dict[str, Any]):\n        update_expr: List[str] = []\n        expression_attribute_names = {}\n        expression_attribute_values = {}\n\n        for index, (attr, value) in enumerate(updates.items()):\n            if not value:\n                continue\n\n            k, v = f\"#{index}\", f\":{index}\"\n            update_expr.append(f\"{k} = {v}\")\n            expression_attribute_names[k] = attr\n            expression_attribute_values[v] = value\n\n        self.client.update_item(\n            TableName=self.table_name,\n            Key=self._serialize_item(key),\n            UpdateExpression=\"SET \" + \", \".join(update_expr),\n            ExpressionAttributeNames=expression_attribute_names,\n            ExpressionAttributeValues=self._serialize_item(expression_attribute_values),\n        )\n\n    @property\n    def context(self):\n        return context\n\n    async def get_user(self, identifier: str) -> Optional[\"PersistedUser\"]:\n        _logger.info(\"DynamoDB: get_user identifier=%s\", identifier)\n\n        response = self.client.get_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"USER#{identifier}\"},\n                \"SK\": {\"S\": \"USER\"},\n            },\n        )\n\n        if \"Item\" not in response:\n            return None\n\n        user = self._deserialize_item(response[\"Item\"])\n\n        return PersistedUser(\n            id=user[\"id\"],\n            identifier=user[\"identifier\"],\n            createdAt=user[\"createdAt\"],\n            metadata=user[\"metadata\"],\n        )\n\n    async def create_user(self, user: \"User\") -> Optional[\"PersistedUser\"]:\n        _logger.info(\"DynamoDB: create_user user.identifier=%s\", user.identifier)\n\n        ts = self._get_current_timestamp()\n        metadata: Dict[Any, Any] = user.metadata  # type: ignore\n\n        item = {\n            \"PK\": f\"USER#{user.identifier}\",\n            \"SK\": \"USER\",\n            \"id\": user.identifier,\n            \"identifier\": user.identifier,\n            \"metadata\": metadata,\n            \"createdAt\": ts,\n        }\n\n        self.client.put_item(\n            TableName=self.table_name,\n            Item=self._serialize_item(item),\n        )\n\n        return PersistedUser(\n            id=user.identifier,\n            identifier=user.identifier,\n            createdAt=ts,\n            metadata=metadata,\n        )\n\n    async def delete_feedback(self, feedback_id: str) -> bool:\n        _logger.info(\"DynamoDB: delete_feedback feedback_id=%s\", feedback_id)\n\n        # feedback id = THREAD#{thread_id}::STEP#{step_id}\n        thread_id, step_id = feedback_id.split(\"::\")\n        thread_id = thread_id.strip(\"THREAD#\")\n        step_id = step_id.strip(\"STEP#\")\n\n        self.client.update_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": f\"STEP#{step_id}\"},\n            },\n            UpdateExpression=\"REMOVE #feedback\",\n            ExpressionAttributeNames={\"#feedback\": \"feedback\"},\n        )\n\n        return True\n\n    async def upsert_feedback(self, feedback: Feedback) -> str:\n        _logger.info(\n            \"DynamoDB: upsert_feedback thread=%s step=%s value=%s\",\n            feedback.threadId,\n            feedback.forId,\n            feedback.value,\n        )\n\n        if not feedback.forId:\n            raise ValueError(\n                \"DynamoDB data layer expects value for feedback.threadId got None\"\n            )\n\n        feedback.id = f\"THREAD#{feedback.threadId}::STEP#{feedback.forId}\"\n        serialized_feedback = self._type_serializer.serialize(asdict(feedback))\n\n        self.client.update_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{feedback.threadId}\"},\n                \"SK\": {\"S\": f\"STEP#{feedback.forId}\"},\n            },\n            UpdateExpression=\"SET #feedback = :feedback\",\n            ExpressionAttributeNames={\"#feedback\": \"feedback\"},\n            ExpressionAttributeValues={\":feedback\": serialized_feedback},\n        )\n\n        return feedback.id\n\n    @queue_until_user_message()\n    async def create_element(self, element: \"Element\"):\n        _logger.info(\n            \"DynamoDB: create_element thread=%s step=%s type=%s\",\n            element.thread_id,\n            element.for_id,\n            element.type,\n        )\n        _logger.debug(\"DynamoDB: create_element: %s\", element.to_dict())\n\n        if not element.for_id:\n            return\n\n        if not self.storage_provider:\n            _logger.warning(\n                \"DynamoDB: create_element error. No storage_provider is configured!\"\n            )\n            return\n\n        content: Optional[Union[bytes, str]] = None\n\n        if element.content:\n            content = element.content\n\n        elif element.path:\n            _logger.debug(\"DynamoDB: create_element reading file %s\", element.path)\n            async with aiofiles.open(element.path, \"rb\") as f:\n                content = await f.read()\n\n        elif element.url:\n            _logger.debug(\"DynamoDB: create_element http %s\", element.url)\n            async with aiohttp.ClientSession() as session:\n                async with session.get(element.url) as response:\n                    if response.status == 200:\n                        content = await response.read()\n                    else:\n                        raise ValueError(\n                            f\"Failed to read content from {element.url} status {response.status}\",\n                        )\n\n        else:\n            raise ValueError(\"Element url, path or content must be provided\")\n\n        if content is None:\n            raise ValueError(\"Content is None, cannot upload file\")\n\n        if not element.mime:\n            element.mime = \"application/octet-stream\"\n\n        context_user = self.context.session.user\n        user_folder = getattr(context_user, \"id\", \"unknown\")\n        file_object_key = f\"{user_folder}/{element.thread_id}/{element.id}\"\n\n        uploaded_file = await self.storage_provider.upload_file(\n            object_key=file_object_key,\n            data=content,\n            mime=element.mime,\n            overwrite=True,\n        )\n        if not uploaded_file:\n            raise ValueError(\n                \"DynamoDB Error: create_element, Failed to persist data in storage_provider\",\n            )\n\n        element_dict: Dict[str, Any] = element.to_dict()  # type: ignore\n        element_dict.update(\n            {\n                \"PK\": f\"THREAD#{element.thread_id}\",\n                \"SK\": f\"ELEMENT#{element.id}\",\n                \"url\": uploaded_file.get(\"url\"),\n                \"objectKey\": uploaded_file.get(\"object_key\"),\n            }\n        )\n\n        self.client.put_item(\n            TableName=self.table_name,\n            Item=self._serialize_item(element_dict),\n        )\n\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        _logger.info(\n            \"DynamoDB: get_element thread=%s element=%s\", thread_id, element_id\n        )\n\n        response = self.client.get_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": f\"ELEMENT#{element_id}\"},\n            },\n        )\n\n        if \"Item\" not in response:\n            return None\n\n        return self._deserialize_item(response[\"Item\"])  # type: ignore\n\n    @queue_until_user_message()\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        thread_id = self.context.session.thread_id\n        _logger.info(\n            \"DynamoDB: delete_element thread=%s element=%s\", thread_id, element_id\n        )\n\n        self.client.delete_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": f\"ELEMENT#{element_id}\"},\n            },\n        )\n\n    @queue_until_user_message()\n    async def create_step(self, step_dict: \"StepDict\"):\n        _logger.info(\n            \"DynamoDB: create_step thread=%s step=%s\",\n            step_dict.get(\"threadId\"),\n            step_dict.get(\"id\"),\n        )\n        _logger.debug(\"DynamoDB: create_step: %s\", step_dict)\n\n        item = dict(step_dict)\n        item.update(\n            {\n                # ignore type, dynamo needs these so we want to fail if not set\n                \"PK\": f\"THREAD#{step_dict['threadId']}\",  # type: ignore\n                \"SK\": f\"STEP#{step_dict['id']}\",  # type: ignore\n            }\n        )\n\n        self.client.put_item(\n            TableName=self.table_name,\n            Item=self._serialize_item(item),\n        )\n\n    @queue_until_user_message()\n    async def update_step(self, step_dict: \"StepDict\"):\n        _logger.info(\n            \"DynamoDB: update_step thread=%s step=%s\",\n            step_dict.get(\"threadId\"),\n            step_dict.get(\"id\"),\n        )\n        _logger.debug(\"DynamoDB: update_step: %s\", step_dict)\n\n        self._update_item(\n            key={\n                # ignore type, dynamo needs these so we want to fail if not set\n                \"PK\": f\"THREAD#{step_dict['threadId']}\",  # type: ignore\n                \"SK\": f\"STEP#{step_dict['id']}\",  # type: ignore\n            },\n            updates=step_dict,  # type: ignore\n        )\n\n    @queue_until_user_message()\n    async def delete_step(self, step_id: str):\n        thread_id = self.context.session.thread_id\n        _logger.info(\"DynamoDB: delete_feedback thread=%s step=%s\", thread_id, step_id)\n\n        self.client.delete_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": f\"STEP#{step_id}\"},\n            },\n        )\n\n    async def get_thread_author(self, thread_id: str) -> str:\n        _logger.info(\"DynamoDB: get_thread_author thread=%s\", thread_id)\n\n        response = self.client.get_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": \"THREAD\"},\n            },\n            ProjectionExpression=\"userId\",\n        )\n\n        if \"Item\" not in response:\n            raise ValueError(f\"Author not found for thread_id {thread_id}\")\n\n        item = self._deserialize_item(response[\"Item\"])\n        return item[\"userId\"]\n\n    async def delete_thread(self, thread_id: str):\n        _logger.info(\"DynamoDB: delete_thread thread=%s\", thread_id)\n\n        thread = await self.get_thread(thread_id)\n        if not thread:\n            return\n\n        items: List[Any] = thread[\"steps\"]\n        if thread[\"elements\"]:\n            items.extend(thread[\"elements\"])\n\n        delete_requests = []\n        for item in items:\n            key = self._serialize_item({\"PK\": item[\"PK\"], \"SK\": item[\"SK\"]})\n            req = {\"DeleteRequest\": {\"Key\": key}}\n            delete_requests.append(req)\n\n        BATCH_ITEM_SIZE = 25  # pylint: disable=invalid-name\n        for i in range(0, len(delete_requests), BATCH_ITEM_SIZE):\n            chunk = delete_requests[i : i + BATCH_ITEM_SIZE]\n            response = self.client.batch_write_item(\n                RequestItems={\n                    self.table_name: chunk,  # type: ignore\n                }\n            )\n\n            backoff_time = 1\n            while response.get(\"UnprocessedItems\"):\n                backoff_time *= 2\n                # Cap the backoff time at 32 seconds & add jitter\n                delay = min(backoff_time, 32) + random.uniform(0, 1)\n                await asyncio.sleep(delay)\n\n                response = self.client.batch_write_item(\n                    RequestItems=response[\"UnprocessedItems\"]\n                )\n\n        self.client.delete_item(\n            TableName=self.table_name,\n            Key={\n                \"PK\": {\"S\": f\"THREAD#{thread_id}\"},\n                \"SK\": {\"S\": \"THREAD\"},\n            },\n        )\n\n    async def list_threads(\n        self, pagination: \"Pagination\", filters: \"ThreadFilter\"\n    ) -> \"PaginatedResponse[ThreadDict]\":\n        _logger.info(\"DynamoDB: list_threads filters.userId=%s\", filters.userId)\n\n        if filters.feedback:\n            _logger.warning(\"DynamoDB: filters on feedback not supported\")\n\n        paginated_response: PaginatedResponse[ThreadDict] = PaginatedResponse(\n            data=[],\n            pageInfo=PageInfo(\n                hasNextPage=False, startCursor=pagination.cursor, endCursor=None\n            ),\n        )\n\n        query_args: Dict[str, Any] = {\n            \"TableName\": self.table_name,\n            \"IndexName\": \"UserThread\",\n            \"ScanIndexForward\": False,\n            \"Limit\": self.user_thread_limit,\n            \"KeyConditionExpression\": \"#UserThreadPK = :pk\",\n            \"ExpressionAttributeNames\": {\n                \"#UserThreadPK\": \"UserThreadPK\",\n            },\n            \"ExpressionAttributeValues\": {\n                \":pk\": {\"S\": f\"USER#{filters.userId}\"},\n            },\n        }\n\n        if pagination.cursor:\n            query_args[\"ExclusiveStartKey\"] = json.loads(pagination.cursor)\n\n        if filters.search:\n            query_args[\"FilterExpression\"] = \"contains(#name, :search)\"\n            query_args[\"ExpressionAttributeNames\"][\"#name\"] = \"name\"\n            query_args[\"ExpressionAttributeValues\"][\":search\"] = {\"S\": filters.search}\n\n        response = self.client.query(**query_args)  # type: ignore\n\n        if \"LastEvaluatedKey\" in response:\n            paginated_response.pageInfo.hasNextPage = True\n            paginated_response.pageInfo.endCursor = json.dumps(\n                response[\"LastEvaluatedKey\"]\n            )\n\n        for item in response[\"Items\"]:\n            deserialized_item: Dict[str, Any] = self._deserialize_item(item)\n            thread = ThreadDict(  # type: ignore\n                id=deserialized_item[\"PK\"].strip(\"THREAD#\"),\n                createdAt=deserialized_item[\"UserThreadSK\"].strip(\"TS#\"),\n                name=deserialized_item[\"name\"],\n            )\n            paginated_response.data.append(thread)\n\n        return paginated_response\n\n    async def get_thread(self, thread_id: str) -> \"Optional[ThreadDict]\":\n        _logger.info(\"DynamoDB: get_thread thread=%s\", thread_id)\n\n        # Get all thread records\n        thread_items: List[Any] = []\n\n        cursor: Dict[str, Any] = {}\n        while True:\n            response = self.client.query(\n                TableName=self.table_name,\n                KeyConditionExpression=\"#pk = :pk\",\n                ExpressionAttributeNames={\"#pk\": \"PK\"},\n                ExpressionAttributeValues={\":pk\": {\"S\": f\"THREAD#{thread_id}\"}},\n                **cursor,\n            )\n\n            deserialized_items = map(self._deserialize_item, response[\"Items\"])\n            thread_items.extend(deserialized_items)\n\n            if \"LastEvaluatedKey\" not in response:\n                break\n            cursor[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n\n        if len(thread_items) == 0:\n            return None\n\n        # process accordingly\n        thread_dict: Optional[ThreadDict] = None\n        steps = []\n        elements = []\n\n        for item in thread_items:\n            if item[\"SK\"] == \"THREAD\":\n                thread_dict = item\n\n            elif item[\"SK\"].startswith(\"ELEMENT\"):\n                if self.storage_provider is not None:\n                    item[\"url\"] = await self.storage_provider.get_read_url(\n                        object_key=item[\"objectKey\"],\n                    )\n                elements.append(item)\n\n            elif item[\"SK\"].startswith(\"STEP\"):\n                if \"feedback\" in item:  # Decimal is not json serializable\n                    item[\"feedback\"][\"value\"] = int(item[\"feedback\"][\"value\"])\n                steps.append(item)\n\n        if not thread_dict:\n            if len(thread_items) > 0:\n                _logger.warning(\n                    \"DynamoDB: found orphaned items for thread=%s\", thread_id\n                )\n            return None\n\n        steps.sort(key=lambda i: i[\"createdAt\"])\n        thread_dict.update(\n            {\n                \"steps\": steps,\n                \"elements\": elements,\n            }\n        )\n\n        return thread_dict\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        _logger.info(\"DynamoDB: update_thread thread=%s userId=%s\", thread_id, user_id)\n        _logger.debug(\n            \"DynamoDB: update_thread name=%s tags=%s metadata=%s\", name, tags, metadata\n        )\n\n        ts = self._get_current_timestamp()\n\n        item = {\n            # GSI: UserThread\n            \"UserThreadSK\": f\"TS#{ts}\",\n            #\n            \"id\": thread_id,\n            \"createdAt\": ts,\n            \"name\": name,\n            \"userId\": user_id,\n            \"userIdentifier\": user_id,\n            \"tags\": tags,\n            \"metadata\": metadata,\n        }\n\n        if user_id:\n            # user_id may be None on subsequent calls, don't update UserThreadPK to \"USER#{None}\"\n            item[\"UserThreadPK\"] = f\"USER#{user_id}\"\n\n        self._update_item(\n            key={\n                \"PK\": f\"THREAD#{thread_id}\",\n                \"SK\": \"THREAD\",\n            },\n            updates=item,\n        )\n\n    async def get_favorite_steps(self, user_id: str) -> List[\"StepDict\"]:\n        _logger.info(\"DynamoDB: get_favorite_steps user_id=%s\", user_id)\n\n        thread_ids = []\n        query_args: Dict[str, Any] = {\n            \"TableName\": self.table_name,\n            \"IndexName\": \"UserThread\",\n            \"KeyConditionExpression\": \"#UserThreadPK = :pk\",\n            \"ExpressionAttributeNames\": {\"#UserThreadPK\": \"UserThreadPK\"},\n            \"ExpressionAttributeValues\": {\":pk\": {\"S\": f\"USER#{user_id}\"}},\n        }\n\n        while True:\n            response = self.client.query(**query_args)  # type: ignore\n            for item in response.get(\"Items\", []):\n                pk = item.get(\"PK\", {}).get(\"S\")\n                if pk:\n                    thread_ids.append(pk.removeprefix(\"THREAD#\"))\n\n            if \"LastEvaluatedKey\" not in response:\n                break\n            query_args[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n\n        favorite_steps: List[Dict[str, Any]] = []\n\n        for thread_id in thread_ids:\n            t_query_args: Dict[str, Any] = {\n                \"TableName\": self.table_name,\n                \"KeyConditionExpression\": \"#pk = :pk AND begins_with(#sk, :sk_prefix)\",\n                \"FilterExpression\": \"#metadata.#favorite = :true\",\n                \"ExpressionAttributeNames\": {\n                    \"#pk\": \"PK\",\n                    \"#sk\": \"SK\",\n                    \"#metadata\": \"metadata\",\n                    \"#favorite\": \"favorite\",\n                },\n                \"ExpressionAttributeValues\": {\n                    \":pk\": {\"S\": f\"THREAD#{thread_id}\"},\n                    \":sk_prefix\": {\"S\": \"STEP#\"},\n                    \":true\": {\"BOOL\": True},\n                },\n            }\n\n            while True:\n                response = self.client.query(**t_query_args)  # type: ignore\n                for item in response.get(\"Items\", []):\n                    step = self._deserialize_item(item)\n                    if \"PK\" in step:\n                        del step[\"PK\"]\n                    if \"SK\" in step:\n                        del step[\"SK\"]\n                    if \"feedback\" in step:\n                        del step[\"feedback\"]\n\n                    favorite_steps.append(step)\n\n                if \"LastEvaluatedKey\" not in response:\n                    break\n                t_query_args[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n\n        favorite_steps.sort(key=lambda x: x.get(\"createdAt\", \"\"), reverse=True)\n        return cast(List[\"StepDict\"], favorite_steps)\n\n    async def build_debug_url(self) -> str:\n        return \"\"\n\n    async def close(self) -> None:\n        if self.storage_provider:\n            await self.storage_provider.close()\n        self.client.close()\n"
  },
  {
    "path": "backend/chainlit/data/literalai.py",
    "content": "import json\n\n# Deprecation warning for users of this provider\nimport sys\nimport warnings\nfrom typing import Dict, List, Literal, Optional, Union, cast\n\nimport aiofiles\nfrom httpx import HTTPStatusError, RequestError\nfrom literalai import (\n    Attachment as LiteralAttachment,\n    Score as LiteralScore,\n    Step as LiteralStep,\n    Thread as LiteralThread,\n)\nfrom literalai.observability.filter import threads_filters as LiteralThreadsFilters\nfrom literalai.observability.step import StepDict as LiteralStepDict\n\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.data.utils import queue_until_user_message\nfrom chainlit.element import Audio, Element, ElementDict, File, Image, Pdf, Text, Video\nfrom chainlit.logger import logger\nfrom chainlit.step import (\n    FeedbackDict,\n    Step,\n    StepDict,\n    StepType,\n    TrueStepType,\n    check_add_step_in_cot,\n    stub_step,\n)\nfrom chainlit.types import (\n    Feedback,\n    PageInfo,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\nfrom chainlit.user import PersistedUser, User\n\n\ndef _show_deprecation_warning():\n    message = (\n        \"\\n\\033[93mWARNING: The LiteralAI data provider is being deprecated and will be turned off on October 31st, 2025.\\033[0m\\n\"\n        \"Please migrate your data layer to another provider as soon as possible.\\n\"\n    )\n    print(message, file=sys.stderr)\n    warnings.warn(message, DeprecationWarning, stacklevel=2)\n\n\n_show_deprecation_warning()\n\n\nclass LiteralToChainlitConverter:\n    @classmethod\n    def steptype_to_steptype(cls, step_type: Optional[StepType]) -> TrueStepType:\n        return cast(TrueStepType, step_type or \"undefined\")\n\n    @classmethod\n    def score_to_feedbackdict(\n        cls,\n        score: Optional[LiteralScore],\n    ) -> \"Optional[FeedbackDict]\":\n        if not score:\n            return None\n        return {\n            \"id\": score.id or \"\",\n            \"forId\": score.step_id or \"\",\n            \"value\": cast(Literal[0, 1], score.value),\n            \"comment\": score.comment,\n        }\n\n    @classmethod\n    def step_to_stepdict(cls, step: LiteralStep) -> \"StepDict\":\n        metadata = step.metadata or {}\n        input = (step.input or {}).get(\"content\") or (\n            json.dumps(step.input) if step.input and step.input != {} else \"\"\n        )\n        output = (step.output or {}).get(\"content\") or (\n            json.dumps(step.output) if step.output and step.output != {} else \"\"\n        )\n\n        user_feedback = (\n            next(\n                (\n                    s\n                    for s in step.scores\n                    if s.type == \"HUMAN\" and s.name == \"user-feedback\"\n                ),\n                None,\n            )\n            if step.scores\n            else None\n        )\n\n        return {\n            \"createdAt\": step.created_at,\n            \"id\": step.id or \"\",\n            \"threadId\": step.thread_id or \"\",\n            \"parentId\": step.parent_id,\n            \"feedback\": cls.score_to_feedbackdict(user_feedback),\n            \"start\": step.start_time,\n            \"end\": step.end_time,\n            \"type\": step.type or \"undefined\",\n            \"name\": step.name or \"\",\n            \"generation\": step.generation.to_dict() if step.generation else None,\n            \"input\": input,\n            \"output\": output,\n            \"showInput\": metadata.get(\"showInput\", False),\n            \"language\": metadata.get(\"language\"),\n            \"isError\": bool(step.error),\n            \"waitForAnswer\": metadata.get(\"waitForAnswer\", False),\n        }\n\n    @classmethod\n    def attachment_to_elementdict(cls, attachment: LiteralAttachment) -> ElementDict:\n        metadata = attachment.metadata or {}\n        return {\n            \"chainlitKey\": None,\n            \"display\": metadata.get(\"display\", \"side\"),\n            \"language\": metadata.get(\"language\"),\n            \"autoPlay\": metadata.get(\"autoPlay\", None),\n            \"playerConfig\": metadata.get(\"playerConfig\", None),\n            \"page\": metadata.get(\"page\"),\n            \"props\": metadata.get(\"props\"),\n            \"size\": metadata.get(\"size\"),\n            \"type\": metadata.get(\"type\", \"file\"),\n            \"forId\": attachment.step_id,\n            \"id\": attachment.id or \"\",\n            \"mime\": attachment.mime,\n            \"name\": attachment.name or \"\",\n            \"objectKey\": attachment.object_key,\n            \"url\": attachment.url,\n            \"threadId\": attachment.thread_id,\n        }\n\n    @classmethod\n    def attachment_to_element(\n        cls, attachment: LiteralAttachment, thread_id: Optional[str] = None\n    ) -> Element:\n        metadata = attachment.metadata or {}\n        element_type = metadata.get(\"type\", \"file\")\n\n        element_class = {\n            \"file\": File,\n            \"image\": Image,\n            \"audio\": Audio,\n            \"video\": Video,\n            \"text\": Text,\n            \"pdf\": Pdf,\n        }.get(element_type, Element)\n\n        assert thread_id or attachment.thread_id\n\n        element = element_class(\n            name=attachment.name or \"\",\n            display=metadata.get(\"display\", \"side\"),\n            language=metadata.get(\"language\"),\n            size=metadata.get(\"size\"),\n            url=attachment.url,\n            mime=attachment.mime,\n            thread_id=thread_id or attachment.thread_id,\n        )\n        element.id = attachment.id or \"\"\n        element.for_id = attachment.step_id\n        element.object_key = attachment.object_key\n        return element\n\n    @classmethod\n    def step_to_step(cls, step: LiteralStep) -> Step:\n        chainlit_step = Step(\n            name=step.name or \"\",\n            type=cls.steptype_to_steptype(step.type),\n            id=step.id,\n            parent_id=step.parent_id,\n            thread_id=step.thread_id or None,\n        )\n        chainlit_step.start = step.start_time\n        chainlit_step.end = step.end_time\n        chainlit_step.created_at = step.created_at\n        chainlit_step.input = step.input.get(\"content\", \"\") if step.input else \"\"\n        chainlit_step.output = step.output.get(\"content\", \"\") if step.output else \"\"\n        chainlit_step.is_error = bool(step.error)\n        chainlit_step.metadata = step.metadata or {}\n        chainlit_step.tags = step.tags\n        chainlit_step.generation = step.generation\n\n        if step.attachments:\n            chainlit_step.elements = [\n                cls.attachment_to_element(attachment, chainlit_step.thread_id)\n                for attachment in step.attachments\n            ]\n\n        return chainlit_step\n\n    @classmethod\n    def thread_to_threaddict(cls, thread: LiteralThread) -> ThreadDict:\n        return {\n            \"id\": thread.id,\n            \"createdAt\": getattr(thread, \"created_at\", \"\"),\n            \"name\": thread.name,\n            \"userId\": thread.participant_id,\n            \"userIdentifier\": thread.participant_identifier,\n            \"tags\": thread.tags,\n            \"metadata\": thread.metadata,\n            \"steps\": [cls.step_to_stepdict(step) for step in thread.steps]\n            if thread.steps\n            else [],\n            \"elements\": [\n                cls.attachment_to_elementdict(attachment)\n                for step in thread.steps\n                for attachment in step.attachments\n            ]\n            if thread.steps\n            else [],\n        }\n\n\nclass LiteralDataLayer(BaseDataLayer):\n    def __init__(self, api_key: str, server: Optional[str]):\n        from literalai import AsyncLiteralClient\n\n        self.client = AsyncLiteralClient(api_key=api_key, url=server)\n        logger.info(\"Chainlit data layer initialized\")\n\n    async def build_debug_url(self) -> str:\n        try:\n            project_id = await self.client.api.get_my_project_id()\n            return f\"{self.client.api.url}/projects/{project_id}/logs/threads/[thread_id]?currentStepId=[step_id]\"\n        except Exception as e:\n            logger.error(f\"Error building debug url: {e}\")\n            return \"\"\n\n    async def get_user(self, identifier: str) -> Optional[PersistedUser]:\n        user = await self.client.api.get_user(identifier=identifier)\n        if not user:\n            return None\n        return PersistedUser(\n            id=user.id or \"\",\n            identifier=user.identifier or \"\",\n            metadata=user.metadata,\n            createdAt=user.created_at or \"\",\n        )\n\n    async def create_user(self, user: User) -> Optional[PersistedUser]:\n        _user = await self.client.api.get_user(identifier=user.identifier)\n        if not _user:\n            _user = await self.client.api.create_user(\n                identifier=user.identifier, metadata=user.metadata\n            )\n        elif _user.id:\n            await self.client.api.update_user(id=_user.id, metadata=user.metadata)\n        return PersistedUser(\n            id=_user.id or \"\",\n            identifier=_user.identifier or \"\",\n            metadata=user.metadata,\n            createdAt=_user.created_at or \"\",\n        )\n\n    async def delete_feedback(\n        self,\n        feedback_id: str,\n    ):\n        if feedback_id:\n            await self.client.api.delete_score(\n                id=feedback_id,\n            )\n            return True\n        return False\n\n    async def upsert_feedback(\n        self,\n        feedback: Feedback,\n    ):\n        if feedback.id:\n            await self.client.api.update_score(\n                id=feedback.id,\n                update_params={\n                    \"comment\": feedback.comment,\n                    \"value\": feedback.value,\n                },\n            )\n            return feedback.id\n        else:\n            created = await self.client.api.create_score(\n                step_id=feedback.forId,\n                value=feedback.value,\n                comment=feedback.comment,\n                name=\"user-feedback\",\n                type=\"HUMAN\",\n            )\n            return created.id or \"\"\n\n    async def safely_send_steps(self, steps):\n        try:\n            await self.client.api.send_steps(steps)\n        except HTTPStatusError as e:\n            logger.error(f\"HTTP Request: error sending steps: {e.response.status_code}\")\n        except RequestError as e:\n            logger.error(f\"HTTP Request: error for {e.request.url!r}.\")\n\n    @queue_until_user_message()\n    async def create_element(self, element: \"Element\"):\n        metadata = {\n            \"size\": element.size,\n            \"language\": element.language,\n            \"display\": element.display,\n            \"type\": element.type,\n            \"page\": getattr(element, \"page\", None),\n            \"props\": getattr(element, \"props\", None),\n        }\n\n        if not element.for_id:\n            return\n\n        object_key = None\n\n        if not element.url:\n            if element.path:\n                async with aiofiles.open(element.path, \"rb\") as f:\n                    content: Union[bytes, str] = await f.read()\n            elif element.content:\n                content = element.content\n            else:\n                raise ValueError(\"Either path or content must be provided\")\n            uploaded = await self.client.api.upload_file(\n                content=content, mime=element.mime, thread_id=element.thread_id\n            )\n            object_key = uploaded[\"object_key\"]\n\n        await self.safely_send_steps(\n            [\n                {\n                    \"id\": element.for_id,\n                    \"threadId\": element.thread_id,\n                    \"attachments\": [\n                        {\n                            \"id\": element.id,\n                            \"name\": element.name,\n                            \"metadata\": metadata,\n                            \"mime\": element.mime,\n                            \"url\": element.url,\n                            \"objectKey\": object_key,\n                        }\n                    ],\n                }\n            ]\n        )\n\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        attachment = await self.client.api.get_attachment(id=element_id)\n        if not attachment:\n            return None\n        return LiteralToChainlitConverter.attachment_to_elementdict(attachment)\n\n    @queue_until_user_message()\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        await self.client.api.delete_attachment(id=element_id)\n\n    @queue_until_user_message()\n    async def create_step(self, step_dict: \"StepDict\"):\n        metadata = dict(\n            step_dict.get(\"metadata\", {}),\n            waitForAnswer=step_dict.get(\"waitForAnswer\"),\n            language=step_dict.get(\"language\"),\n            showInput=step_dict.get(\"showInput\"),\n        )\n\n        step: LiteralStepDict = {\n            \"createdAt\": step_dict.get(\"createdAt\"),\n            \"startTime\": step_dict.get(\"start\"),\n            \"endTime\": step_dict.get(\"end\"),\n            \"generation\": step_dict.get(\"generation\"),\n            \"id\": step_dict.get(\"id\"),\n            \"parentId\": step_dict.get(\"parentId\"),\n            \"name\": step_dict.get(\"name\"),\n            \"threadId\": step_dict.get(\"threadId\"),\n            \"type\": step_dict.get(\"type\"),\n            \"tags\": step_dict.get(\"tags\"),\n            \"metadata\": metadata,\n        }\n        if step_dict.get(\"input\"):\n            step[\"input\"] = {\"content\": step_dict.get(\"input\")}\n        if step_dict.get(\"output\"):\n            step[\"output\"] = {\"content\": step_dict.get(\"output\")}\n        if step_dict.get(\"isError\"):\n            step[\"error\"] = step_dict.get(\"output\")\n\n        await self.safely_send_steps([step])\n\n    @queue_until_user_message()\n    async def update_step(self, step_dict: \"StepDict\"):\n        await self.create_step(step_dict)\n\n    @queue_until_user_message()\n    async def delete_step(self, step_id: str):\n        await self.client.api.delete_step(id=step_id)\n\n    async def get_thread_author(self, thread_id: str) -> str:\n        thread = await self.get_thread(thread_id)\n        if not thread:\n            return \"\"\n        user_identifier = thread.get(\"userIdentifier\")\n        if not user_identifier:\n            return \"\"\n\n        return user_identifier\n\n    async def delete_thread(self, thread_id: str):\n        await self.client.api.delete_thread(id=thread_id)\n\n    async def list_threads(\n        self, pagination: \"Pagination\", filters: \"ThreadFilter\"\n    ) -> \"PaginatedResponse[ThreadDict]\":\n        if not filters.userId:\n            raise ValueError(\"userId is required\")\n\n        literal_filters: LiteralThreadsFilters = [\n            {\n                \"field\": \"participantId\",\n                \"operator\": \"eq\",\n                \"value\": filters.userId,\n            }\n        ]\n\n        if filters.search:\n            literal_filters.append(\n                {\n                    \"field\": \"stepOutput\",\n                    \"operator\": \"ilike\",\n                    \"value\": filters.search,\n                    \"path\": \"content\",\n                }\n            )\n\n        if filters.feedback is not None:\n            literal_filters.append(\n                {\n                    \"field\": \"scoreValue\",\n                    \"operator\": \"eq\",\n                    \"value\": filters.feedback,\n                    \"path\": \"user-feedback\",\n                }\n            )\n\n        literal_response = await self.client.api.list_threads(\n            first=pagination.first,\n            after=pagination.cursor,\n            filters=literal_filters,\n            order_by={\"column\": \"createdAt\", \"direction\": \"DESC\"},\n        )\n\n        chainlit_threads = [\n            *map(LiteralToChainlitConverter.thread_to_threaddict, literal_response.data)\n        ]\n\n        return PaginatedResponse(\n            pageInfo=PageInfo(\n                hasNextPage=literal_response.page_info.has_next_page,\n                startCursor=literal_response.page_info.start_cursor,\n                endCursor=literal_response.page_info.end_cursor,\n            ),\n            data=chainlit_threads,\n        )\n\n    async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:\n        thread = await self.client.api.get_thread(id=thread_id)\n        if not thread:\n            return None\n\n        elements: List[ElementDict] = []\n        steps: List[StepDict] = []\n        if thread.steps:\n            for step in thread.steps:\n                for attachment in step.attachments:\n                    elements.append(\n                        LiteralToChainlitConverter.attachment_to_elementdict(attachment)\n                    )\n\n                chainlit_step = LiteralToChainlitConverter.step_to_step(step)\n                if check_add_step_in_cot(chainlit_step):\n                    steps.append(\n                        LiteralToChainlitConverter.step_to_stepdict(step)\n                    )  # TODO: chainlit_step.to_dict()\n                else:\n                    steps.append(stub_step(chainlit_step))\n\n        return {\n            \"createdAt\": thread.created_at or \"\",\n            \"id\": thread.id,\n            \"name\": thread.name or None,\n            \"steps\": steps,\n            \"elements\": elements,\n            \"metadata\": thread.metadata,\n            \"userId\": thread.participant_id,\n            \"userIdentifier\": thread.participant_identifier,\n            \"tags\": thread.tags,\n        }\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        await self.client.api.upsert_thread(\n            id=thread_id,\n            name=name,\n            participant_id=user_id,\n            metadata=metadata,\n            tags=tags,\n        )\n\n    async def get_favorite_steps(self, user_id: str) -> List[StepDict]:\n        \"\"\"noop for literalai\"\"\"\n        return []\n\n    async def close(self):\n        self.client.flush_and_stop()\n"
  },
  {
    "path": "backend/chainlit/data/sql_alchemy.py",
    "content": "import json\nimport ssl\nimport uuid\nfrom dataclasses import asdict\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Union\n\nimport aiofiles\nimport aiohttp\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import SQLAlchemyError\nfrom sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine\nfrom sqlalchemy.orm import sessionmaker\n\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.data.utils import queue_until_user_message\nfrom chainlit.element import ElementDict\nfrom chainlit.logger import logger\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    Feedback,\n    FeedbackDict,\n    PageInfo,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\nfrom chainlit.user import PersistedUser, User\n\nif TYPE_CHECKING:\n    from chainlit.element import Element, ElementDict\n    from chainlit.step import StepDict\n\n\nclass SQLAlchemyDataLayer(BaseDataLayer):\n    def __init__(\n        self,\n        conninfo: str,\n        connect_args: Optional[dict[str, Any]] = None,\n        ssl_require: bool = False,\n        storage_provider: Optional[BaseStorageClient] = None,\n        user_thread_limit: Optional[int] = 1000,\n        show_logger: Optional[bool] = False,\n    ):\n        self._conninfo = conninfo\n        self.user_thread_limit = user_thread_limit\n        self.show_logger = show_logger\n        if connect_args is None:\n            connect_args = {}\n        if ssl_require:\n            # Create an SSL context to require an SSL connection\n            ssl_context = ssl.create_default_context()\n            ssl_context.check_hostname = False\n            ssl_context.verify_mode = ssl.CERT_NONE\n            connect_args[\"ssl\"] = ssl_context\n        self.engine: AsyncEngine = create_async_engine(\n            self._conninfo, connect_args=connect_args\n        )\n        self.async_session = sessionmaker(\n            bind=self.engine, expire_on_commit=False, class_=AsyncSession\n        )  # type: ignore\n        if storage_provider:\n            self.storage_provider: Optional[BaseStorageClient] = storage_provider\n            if self.show_logger:\n                logger.info(\"SQLAlchemyDataLayer storage client initialized\")\n        else:\n            self.storage_provider = None\n            logger.warning(\n                \"SQLAlchemyDataLayer storage client is not initialized and elements will not be persisted!\"\n            )\n\n    async def build_debug_url(self) -> str:\n        return \"\"\n\n    ###### SQL Helpers ######\n    async def execute_sql(\n        self, query: str, parameters: dict\n    ) -> Union[List[Dict[str, Any]], int, None]:\n        parameterized_query = text(query)\n        async with self.async_session() as session:\n            try:\n                await session.begin()\n                result = await session.execute(parameterized_query, parameters)\n                await session.commit()\n                if result.returns_rows:\n                    json_result = [dict(row._mapping) for row in result.fetchall()]\n                    clean_json_result = self.clean_result(json_result)\n                    assert isinstance(clean_json_result, list) or isinstance(\n                        clean_json_result, int\n                    )\n                    return clean_json_result\n                else:\n                    return result.rowcount\n            except SQLAlchemyError as e:\n                await session.rollback()\n                logger.warning(f\"An error occurred: {e}\")\n                return None\n            except Exception as e:\n                await session.rollback()\n                logger.warning(f\"An unexpected error occurred: {e}\")\n                return None\n\n    async def get_current_timestamp(self) -> str:\n        return datetime.now().isoformat() + \"Z\"\n\n    def clean_result(self, obj):\n        \"\"\"Recursively change UUID -> str and serialize dictionaries\"\"\"\n        if isinstance(obj, dict):\n            return {k: self.clean_result(v) for k, v in obj.items()}\n        elif isinstance(obj, list):\n            return [self.clean_result(item) for item in obj]\n        elif isinstance(obj, uuid.UUID):\n            return str(obj)\n        return obj\n\n    ###### User ######\n    async def get_user(self, identifier: str) -> Optional[PersistedUser]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: get_user, identifier={identifier}\")\n        query = \"SELECT * FROM users WHERE identifier = :identifier\"\n        parameters = {\"identifier\": identifier}\n        result = await self.execute_sql(query=query, parameters=parameters)\n        if result and isinstance(result, list):\n            user_data = result[0]\n\n            # SQLite returns JSON as string, we most convert it. (#1137)\n            metadata = user_data.get(\"metadata\", {})\n            if isinstance(metadata, str):\n                metadata = json.loads(metadata)\n\n            assert isinstance(metadata, dict)\n            assert isinstance(user_data[\"id\"], str)\n            assert isinstance(user_data[\"identifier\"], str)\n            assert isinstance(user_data[\"createdAt\"], str)\n\n            return PersistedUser(\n                id=user_data[\"id\"],\n                identifier=user_data[\"identifier\"],\n                createdAt=user_data[\"createdAt\"],\n                metadata=metadata,\n            )\n        return None\n\n    async def _get_user_identifer_by_id(self, user_id: str) -> str:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: _get_user_identifer_by_id, user_id={user_id}\")\n        query = \"SELECT identifier FROM users WHERE id = :user_id\"\n        parameters = {\"user_id\": user_id}\n        result = await self.execute_sql(query=query, parameters=parameters)\n\n        assert result\n        assert isinstance(result, list)\n\n        return result[0][\"identifier\"]\n\n    async def _get_user_id_by_thread(self, thread_id: str) -> Optional[str]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: _get_user_id_by_thread, thread_id={thread_id}\")\n        query = \"\"\"SELECT \"userId\" FROM threads WHERE id = :thread_id\"\"\"\n        parameters = {\"thread_id\": thread_id}\n        result = await self.execute_sql(query=query, parameters=parameters)\n        if result:\n            assert isinstance(result, list)\n            return result[0][\"userId\"]\n\n        return None\n\n    async def create_user(self, user: User) -> Optional[PersistedUser]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: create_user, user_identifier={user.identifier}\")\n        existing_user: Optional[PersistedUser] = await self.get_user(user.identifier)\n        user_dict: Dict[str, Any] = {\n            \"identifier\": str(user.identifier),\n            \"metadata\": json.dumps(user.metadata) or {},\n        }\n        if not existing_user:  # create the user\n            if self.show_logger:\n                logger.info(\"SQLAlchemy: create_user, creating the user\")\n            user_dict[\"id\"] = str(uuid.uuid4())\n            user_dict[\"createdAt\"] = await self.get_current_timestamp()\n            query = \"\"\"INSERT INTO users (\"id\", \"identifier\", \"createdAt\", \"metadata\") VALUES (:id, :identifier, :createdAt, :metadata)\"\"\"\n            await self.execute_sql(query=query, parameters=user_dict)\n        else:  # update the user\n            if self.show_logger:\n                logger.info(\"SQLAlchemy: update user metadata\")\n            query = \"\"\"UPDATE users SET \"metadata\" = :metadata WHERE \"identifier\" = :identifier\"\"\"\n            await self.execute_sql(\n                query=query, parameters=user_dict\n            )  # We want to update the metadata\n        return await self.get_user(user.identifier)\n\n    ###### Threads ######\n    async def get_thread_author(self, thread_id: str) -> str:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: get_thread_author, thread_id={thread_id}\")\n        query = \"\"\"SELECT \"userIdentifier\" FROM threads WHERE \"id\" = :id\"\"\"\n        parameters = {\"id\": thread_id}\n        result = await self.execute_sql(query=query, parameters=parameters)\n        if isinstance(result, list) and result:\n            author_identifier = result[0].get(\"userIdentifier\")\n            if author_identifier is not None:\n                return author_identifier\n        raise ValueError(f\"Author not found for thread_id {thread_id}\")\n\n    async def get_thread(self, thread_id: str) -> Optional[ThreadDict]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: get_thread, thread_id={thread_id}\")\n        user_threads: Optional[List[ThreadDict]] = await self.get_all_user_threads(\n            thread_id=thread_id\n        )\n        if user_threads:\n            return user_threads[0]\n        else:\n            return None\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: update_thread, thread_id={thread_id}\")\n\n        user_identifier = None\n        if user_id:\n            user_identifier = await self._get_user_identifer_by_id(user_id)\n\n        if metadata is not None:\n            existing = await self.execute_sql(\n                query='SELECT \"metadata\" FROM threads WHERE \"id\" = :id',\n                parameters={\"id\": thread_id},\n            )\n            base = {}\n            if isinstance(existing, list) and existing:\n                raw = existing[0].get(\"metadata\") or {}\n                if isinstance(raw, str):\n                    try:\n                        base = json.loads(raw)\n                    except json.JSONDecodeError:\n                        base = {}\n                elif isinstance(raw, dict):\n                    base = raw\n            incoming = {k: v for k, v in metadata.items() if v is not None}\n            metadata = {**base, **incoming}\n\n        name_value = name\n        if name_value is None and metadata:\n            name_value = metadata.get(\"name\")\n        created_at_value = (\n            await self.get_current_timestamp() if metadata is None else None\n        )\n\n        data = {\n            \"id\": thread_id,\n            \"createdAt\": created_at_value,\n            \"name\": name_value,\n            \"userId\": user_id,\n            \"userIdentifier\": user_identifier,\n            \"tags\": tags,\n            \"metadata\": json.dumps(metadata) if metadata else None,\n        }\n        parameters = {\n            key: value for key, value in data.items() if value is not None\n        }  # Remove keys with None values\n        columns = \", \".join(f'\"{key}\"' for key in parameters.keys())\n        values = \", \".join(f\":{key}\" for key in parameters.keys())\n        updates = \", \".join(\n            f'\"{key}\" = EXCLUDED.\"{key}\"' for key in parameters.keys() if key != \"id\"\n        )\n        query = f\"\"\"\n            INSERT INTO threads ({columns})\n            VALUES ({values})\n            ON CONFLICT (\"id\") DO UPDATE\n            SET {updates};\n        \"\"\"\n        await self.execute_sql(query=query, parameters=parameters)\n\n    async def delete_thread(self, thread_id: str):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: delete_thread, thread_id={thread_id}\")\n\n        elements_query = \"\"\"SELECT * FROM elements WHERE \"threadId\" = :id\"\"\"\n        elements = await self.execute_sql(elements_query, {\"id\": thread_id})\n\n        if self.storage_provider is not None and isinstance(elements, list):\n            for elem in filter(lambda x: x[\"objectKey\"], elements):\n                await self.storage_provider.delete_file(object_key=elem[\"objectKey\"])\n\n        # Delete feedbacks/elements/steps/thread\n        feedbacks_query = \"\"\"DELETE FROM feedbacks WHERE \"forId\" IN (SELECT \"id\" FROM steps WHERE \"threadId\" = :id)\"\"\"\n        elements_query = \"\"\"DELETE FROM elements WHERE \"threadId\" = :id\"\"\"\n        steps_query = \"\"\"DELETE FROM steps WHERE \"threadId\" = :id\"\"\"\n        thread_query = \"\"\"DELETE FROM threads WHERE \"id\" = :id\"\"\"\n        parameters = {\"id\": thread_id}\n        await self.execute_sql(query=feedbacks_query, parameters=parameters)\n        await self.execute_sql(query=elements_query, parameters=parameters)\n        await self.execute_sql(query=steps_query, parameters=parameters)\n        await self.execute_sql(query=thread_query, parameters=parameters)\n\n    async def list_threads(\n        self, pagination: Pagination, filters: ThreadFilter\n    ) -> PaginatedResponse:\n        if self.show_logger:\n            logger.info(\n                f\"SQLAlchemy: list_threads, pagination={pagination}, filters={filters}\"\n            )\n        if not filters.userId:\n            raise ValueError(\"userId is required\")\n        all_user_threads: List[ThreadDict] = (\n            await self.get_all_user_threads(user_id=filters.userId) or []\n        )\n\n        search_keyword = filters.search.lower() if filters.search else None\n        feedback_value = int(filters.feedback) if filters.feedback else None\n\n        filtered_threads = []\n        for thread in all_user_threads:\n            keyword_match = True\n            feedback_match = True\n            if search_keyword or feedback_value is not None:\n                if search_keyword:\n                    keyword_match = any(\n                        search_keyword in step[\"output\"].lower()\n                        for step in thread[\"steps\"]\n                        if \"output\" in step\n                    )\n                if feedback_value is not None:\n                    feedback_match = False  # Assume no match until found\n                    for step in thread[\"steps\"]:\n                        feedback = step.get(\"feedback\")\n                        if feedback and feedback.get(\"value\") == feedback_value:\n                            feedback_match = True\n                            break\n            if keyword_match and feedback_match:\n                filtered_threads.append(thread)\n\n        start = 0\n        if pagination.cursor:\n            for i, thread in enumerate(filtered_threads):\n                if (\n                    thread[\"id\"] == pagination.cursor\n                ):  # Find the start index using pagination.cursor\n                    start = i + 1\n                    break\n        end = start + pagination.first\n        paginated_threads = filtered_threads[start:end] or []\n\n        has_next_page = len(filtered_threads) > end\n        start_cursor = paginated_threads[0][\"id\"] if paginated_threads else None\n        end_cursor = paginated_threads[-1][\"id\"] if paginated_threads else None\n\n        return PaginatedResponse(\n            pageInfo=PageInfo(\n                hasNextPage=has_next_page,\n                startCursor=start_cursor,\n                endCursor=end_cursor,\n            ),\n            data=paginated_threads,\n        )\n\n    ###### Steps ######\n    @queue_until_user_message()\n    async def create_step(self, step_dict: \"StepDict\"):\n        await self.update_thread(step_dict[\"threadId\"])\n\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: create_step, step_id={step_dict.get('id')}\")\n\n        step_dict[\"showInput\"] = (\n            str(step_dict.get(\"showInput\", \"\")).lower()\n            if \"showInput\" in step_dict\n            else None\n        )\n        parameters = {\n            key: value\n            for key, value in step_dict.items()\n            if value is not None and not (isinstance(value, dict) and not value)\n        }\n        parameters[\"metadata\"] = json.dumps(step_dict.get(\"metadata\", {}))\n        parameters[\"generation\"] = json.dumps(step_dict.get(\"generation\", {}))\n        columns = \", \".join(f'\"{key}\"' for key in parameters.keys())\n        values = \", \".join(f\":{key}\" for key in parameters.keys())\n        updates = \", \".join(\n            f'\"{key}\" = :{key}' for key in parameters.keys() if key != \"id\"\n        )\n        query = f\"\"\"\n            INSERT INTO steps ({columns})\n            VALUES ({values})\n            ON CONFLICT (id) DO UPDATE\n            SET {updates};\n        \"\"\"\n        await self.execute_sql(query=query, parameters=parameters)\n\n    @queue_until_user_message()\n    async def update_step(self, step_dict: \"StepDict\"):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: update_step, step_id={step_dict.get('id')}\")\n        await self.create_step(step_dict)\n\n    @queue_until_user_message()\n    async def delete_step(self, step_id: str):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: delete_step, step_id={step_id}\")\n        # Delete feedbacks/elements/steps\n        feedbacks_query = \"\"\"DELETE FROM feedbacks WHERE \"forId\" = :id\"\"\"\n        elements_query = \"\"\"DELETE FROM elements WHERE \"forId\" = :id\"\"\"\n        steps_query = \"\"\"DELETE FROM steps WHERE \"id\" = :id\"\"\"\n        parameters = {\"id\": step_id}\n        await self.execute_sql(query=feedbacks_query, parameters=parameters)\n        await self.execute_sql(query=elements_query, parameters=parameters)\n        await self.execute_sql(query=steps_query, parameters=parameters)\n\n    async def get_step(self, step_id: str) -> Optional[\"StepDict\"]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: get_step, step_id={step_id}\")\n        steps_feedbacks_query = \"\"\"\n            SELECT\n                s.\"id\" AS step_id,\n                s.\"name\" AS step_name,\n                s.\"type\" AS step_type,\n                s.\"threadId\" AS step_threadid,\n                s.\"parentId\" AS step_parentid,\n                s.\"streaming\" AS step_streaming,\n                s.\"waitForAnswer\" AS step_waitforanswer,\n                s.\"isError\" AS step_iserror,\n                s.\"metadata\" AS step_metadata,\n                s.\"tags\" AS step_tags,\n                s.\"input\" AS step_input,\n                s.\"output\" AS step_output,\n                s.\"createdAt\" AS step_createdat,\n                s.\"start\" AS step_start,\n                s.\"end\" AS step_end,\n                s.\"generation\" AS step_generation,\n                s.\"showInput\" AS step_showinput,\n                s.\"language\" AS step_language,\n                f.\"value\" AS feedback_value,\n                f.\"comment\" AS feedback_comment,\n                f.\"id\" AS feedback_id\n            FROM steps s LEFT JOIN feedbacks f ON s.\"id\" = f.\"forId\"\n            WHERE s.\"id\" = :step_id\n        \"\"\"\n        steps_feedbacks = await self.execute_sql(\n            query=steps_feedbacks_query, parameters={\"step_id\": step_id}\n        )\n\n        if not isinstance(steps_feedbacks, list) or not steps_feedbacks:\n            return None\n\n        step_feedback = steps_feedbacks[0]\n\n        feedback = None\n        if step_feedback[\"feedback_value\"] is not None:\n            feedback = FeedbackDict(\n                forId=step_feedback[\"step_id\"],\n                id=step_feedback.get(\"feedback_id\"),\n                value=step_feedback[\"feedback_value\"],\n                comment=step_feedback.get(\"feedback_comment\"),\n            )\n        return StepDict(\n            id=step_feedback[\"step_id\"],\n            name=step_feedback[\"step_name\"],\n            type=step_feedback[\"step_type\"],\n            threadId=step_feedback.get(\"step_threadid\", \"\"),\n            parentId=step_feedback.get(\"step_parentid\"),\n            streaming=step_feedback.get(\"step_streaming\", False),\n            waitForAnswer=step_feedback.get(\"step_waitforanswer\"),\n            isError=step_feedback.get(\"step_iserror\"),\n            metadata=(\n                step_feedback[\"step_metadata\"]\n                if step_feedback.get(\"step_metadata\") is not None\n                else {}\n            ),\n            tags=step_feedback.get(\"step_tags\"),\n            input=(\n                step_feedback.get(\"step_input\", \"\")\n                if step_feedback.get(\"step_showinput\") not in [None, \"false\"]\n                else \"\"\n            ),\n            output=step_feedback.get(\"step_output\", \"\"),\n            createdAt=step_feedback.get(\"step_createdat\"),\n            start=step_feedback.get(\"step_start\"),\n            end=step_feedback.get(\"step_end\"),\n            generation=step_feedback.get(\"step_generation\"),\n            showInput=step_feedback.get(\"step_showinput\"),\n            language=step_feedback.get(\"step_language\"),\n            feedback=feedback,\n        )\n\n    ###### Feedback ######\n    async def upsert_feedback(self, feedback: Feedback) -> str:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: upsert_feedback, feedback_id={feedback.id}\")\n        feedback.id = feedback.id or str(uuid.uuid4())\n        feedback_dict = asdict(feedback)\n        parameters = {\n            key: value for key, value in feedback_dict.items() if value is not None\n        }\n\n        columns = \", \".join(f'\"{key}\"' for key in parameters.keys())\n        values = \", \".join(f\":{key}\" for key in parameters.keys())\n        updates = \", \".join(\n            f'\"{key}\" = :{key}' for key in parameters.keys() if key != \"id\"\n        )\n        query = f\"\"\"\n            INSERT INTO feedbacks ({columns})\n            VALUES ({values})\n            ON CONFLICT (id) DO UPDATE\n            SET {updates};\n        \"\"\"\n        await self.execute_sql(query=query, parameters=parameters)\n        return feedback.id\n\n    async def delete_feedback(self, feedback_id: str) -> bool:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: delete_feedback, feedback_id={feedback_id}\")\n        query = \"\"\"DELETE FROM feedbacks WHERE \"id\" = :feedback_id\"\"\"\n        parameters = {\"feedback_id\": feedback_id}\n        await self.execute_sql(query=query, parameters=parameters)\n        return True\n\n    ###### Elements ######\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        if self.show_logger:\n            logger.info(\n                f\"SQLAlchemy: get_element, thread_id={thread_id}, element_id={element_id}\"\n            )\n        query = \"\"\"SELECT * FROM elements WHERE \"threadId\" = :thread_id AND \"id\" = :element_id\"\"\"\n        parameters = {\"thread_id\": thread_id, \"element_id\": element_id}\n        element: Union[List[Dict[str, Any]], int, None] = await self.execute_sql(\n            query=query, parameters=parameters\n        )\n        if isinstance(element, list) and element:\n            element_dict: Dict[str, Any] = element[0]\n            return ElementDict(\n                id=element_dict[\"id\"],\n                threadId=element_dict.get(\"threadId\"),\n                type=element_dict[\"type\"],\n                chainlitKey=element_dict.get(\"chainlitKey\"),\n                url=element_dict.get(\"url\"),\n                objectKey=element_dict.get(\"objectKey\"),\n                name=element_dict[\"name\"],\n                props=json.loads(element_dict.get(\"props\", \"{}\")),\n                display=element_dict[\"display\"],\n                size=element_dict.get(\"size\"),\n                language=element_dict.get(\"language\"),\n                page=element_dict.get(\"page\"),\n                autoPlay=element_dict.get(\"autoPlay\"),\n                playerConfig=element_dict.get(\"playerConfig\"),\n                forId=element_dict.get(\"forId\"),\n                mime=element_dict.get(\"mime\"),\n            )\n        else:\n            return None\n\n    @queue_until_user_message()\n    async def create_element(self, element: \"Element\"):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: create_element, element_id = {element.id}\")\n\n        if not self.storage_provider:\n            logger.warning(\n                \"SQLAlchemy: create_element error. No blob_storage_client is configured!\"\n            )\n            return\n        if not element.for_id:\n            return\n\n        content: Optional[Union[bytes, str]] = None\n\n        if element.path:\n            async with aiofiles.open(element.path, \"rb\") as f:\n                content = await f.read()\n        elif element.url:\n            async with aiohttp.ClientSession() as session:\n                async with session.get(element.url) as response:\n                    if response.status == 200:\n                        content = await response.read()\n                    else:\n                        content = None\n        elif element.content:\n            content = element.content\n        else:\n            raise ValueError(\"Element url, path or content must be provided\")\n        if content is None:\n            raise ValueError(\"Content is None, cannot upload file\")\n\n        user_id: str = await self._get_user_id_by_thread(element.thread_id) or \"unknown\"\n        file_object_key = f\"{user_id}/{element.id}\" + (\n            f\"/{element.name}\" if element.name else \"\"\n        )\n\n        if not element.mime:\n            element.mime = \"application/octet-stream\"\n\n        uploaded_file = await self.storage_provider.upload_file(\n            object_key=file_object_key, data=content, mime=element.mime, overwrite=True\n        )\n        if not uploaded_file:\n            raise ValueError(\n                \"SQLAlchemy Error: create_element, Failed to persist data in storage_provider\"\n            )\n\n        element_dict: ElementDict = element.to_dict()\n\n        element_dict[\"url\"] = uploaded_file.get(\"url\")\n        element_dict[\"objectKey\"] = uploaded_file.get(\"object_key\")\n\n        element_dict_cleaned = {k: v for k, v in element_dict.items() if v is not None}\n        if \"props\" in element_dict_cleaned:\n            element_dict_cleaned[\"props\"] = json.dumps(element_dict_cleaned[\"props\"])\n\n        columns = \", \".join(f'\"{column}\"' for column in element_dict_cleaned.keys())\n        placeholders = \", \".join(f\":{column}\" for column in element_dict_cleaned.keys())\n        updates = \", \".join(\n            f'\"{column}\" = :{column}'\n            for column in element_dict_cleaned.keys()\n            if column != \"id\"\n        )\n        query = f\"INSERT INTO elements ({columns}) VALUES ({placeholders}) ON CONFLICT (id) DO UPDATE SET {updates};\"\n        await self.execute_sql(query=query, parameters=element_dict_cleaned)\n\n    @queue_until_user_message()\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: delete_element, element_id={element_id}\")\n\n        query = \"\"\"SELECT * FROM elements WHERE \"id\" = :id\"\"\"\n        elements = await self.execute_sql(query, {\"id\": element_id})\n\n        if (\n            self.storage_provider is not None\n            and isinstance(elements, list)\n            and len(elements) > 0\n            and elements[0][\"objectKey\"]\n        ):\n            await self.storage_provider.delete_file(object_key=elements[0][\"objectKey\"])\n\n        query = \"\"\"DELETE FROM elements WHERE \"id\" = :id\"\"\"\n        parameters = {\"id\": element_id}\n\n        await self.execute_sql(query=query, parameters=parameters)\n\n    async def get_all_user_threads(\n        self, user_id: Optional[str] = None, thread_id: Optional[str] = None\n    ) -> Optional[List[ThreadDict]]:\n        \"\"\"Fetch all user threads up to self.user_thread_limit, or one thread by id if thread_id is provided.\"\"\"\n        if self.show_logger:\n            logger.info(\"SQLAlchemy: get_all_user_threads\")\n        user_threads_query = \"\"\"\n            SELECT\n                t.\"id\" AS thread_id,\n                t.\"createdAt\" AS thread_createdat,\n                t.\"name\" AS thread_name,\n                t.\"userId\" AS user_id,\n                t.\"userIdentifier\" AS user_identifier,\n                t.\"tags\" AS thread_tags,\n                t.\"metadata\" AS thread_metadata,\n                MAX(s.\"createdAt\") AS updatedAt\n            FROM threads t\n            LEFT JOIN steps s ON t.\"id\" = s.\"threadId\"\n            WHERE t.\"userId\" = :user_id OR t.\"id\" = :thread_id\n            GROUP BY\n                t.\"id\",\n                t.\"createdAt\",\n                t.\"name\",\n                t.\"userId\",\n                t.\"userIdentifier\",\n                t.\"tags\",\n                t.\"metadata\"\n            ORDER BY updatedAt DESC NULLS LAST\n            LIMIT :limit\n        \"\"\"\n        user_threads = await self.execute_sql(\n            query=user_threads_query,\n            parameters={\n                \"user_id\": user_id,\n                \"limit\": self.user_thread_limit,\n                \"thread_id\": thread_id,\n            },\n        )\n        if not isinstance(user_threads, list):\n            return None\n        if not user_threads:\n            return []\n        else:\n            thread_ids = (\n                \"('\"\n                + \"','\".join(map(str, [thread[\"thread_id\"] for thread in user_threads]))\n                + \"')\"\n            )\n\n        steps_feedbacks_query = f\"\"\"\n            SELECT\n                s.\"id\" AS step_id,\n                s.\"name\" AS step_name,\n                s.\"type\" AS step_type,\n                s.\"threadId\" AS step_threadid,\n                s.\"parentId\" AS step_parentid,\n                s.\"streaming\" AS step_streaming,\n                s.\"waitForAnswer\" AS step_waitforanswer,\n                s.\"isError\" AS step_iserror,\n                s.\"metadata\" AS step_metadata,\n                s.\"tags\" AS step_tags,\n                s.\"input\" AS step_input,\n                s.\"output\" AS step_output,\n                s.\"createdAt\" AS step_createdat,\n                s.\"start\" AS step_start,\n                s.\"end\" AS step_end,\n                s.\"generation\" AS step_generation,\n                s.\"showInput\" AS step_showinput,\n                s.\"language\" AS step_language,\n                f.\"value\" AS feedback_value,\n                f.\"comment\" AS feedback_comment,\n                f.\"id\" AS feedback_id\n            FROM steps s LEFT JOIN feedbacks f ON s.\"id\" = f.\"forId\"\n            WHERE s.\"threadId\" IN {thread_ids}\n            ORDER BY s.\"createdAt\" ASC\n        \"\"\"\n        steps_feedbacks = await self.execute_sql(\n            query=steps_feedbacks_query, parameters={}\n        )\n\n        elements_query = f\"\"\"\n            SELECT\n                e.\"id\" AS element_id,\n                e.\"threadId\" as element_threadid,\n                e.\"type\" AS element_type,\n                e.\"chainlitKey\" AS element_chainlitkey,\n                e.\"url\" AS element_url,\n                e.\"objectKey\" as element_objectkey,\n                e.\"name\" AS element_name,\n                e.\"display\" AS element_display,\n                e.\"size\" AS element_size,\n                e.\"language\" AS element_language,\n                e.\"page\" AS element_page,\n                e.\"forId\" AS element_forid,\n                e.\"mime\" AS element_mime,\n                e.\"props\" AS props\n            FROM elements e\n            WHERE e.\"threadId\" IN {thread_ids}\n        \"\"\"\n        elements = await self.execute_sql(query=elements_query, parameters={})\n\n        thread_dicts = {}\n        for thread in user_threads:\n            thread_id = thread[\"thread_id\"]\n            if thread_id is not None:\n                thread_dicts[thread_id] = ThreadDict(\n                    id=thread_id,\n                    createdAt=thread[\"thread_createdat\"],\n                    name=thread[\"thread_name\"],\n                    userId=thread[\"user_id\"],\n                    userIdentifier=thread[\"user_identifier\"],\n                    tags=thread[\"thread_tags\"],\n                    metadata=thread[\"thread_metadata\"],\n                    steps=[],\n                    elements=[],\n                )\n        # Process steps_feedbacks to populate the steps in the corresponding ThreadDict\n        if isinstance(steps_feedbacks, list):\n            for step_feedback in steps_feedbacks:\n                thread_id = step_feedback[\"step_threadid\"]\n                if thread_id is not None:\n                    feedback = None\n                    if step_feedback[\"feedback_value\"] is not None:\n                        feedback = FeedbackDict(\n                            forId=step_feedback[\"step_id\"],\n                            id=step_feedback.get(\"feedback_id\"),\n                            value=step_feedback[\"feedback_value\"],\n                            comment=step_feedback.get(\"feedback_comment\"),\n                        )\n                    step_dict = StepDict(\n                        id=step_feedback[\"step_id\"],\n                        name=step_feedback[\"step_name\"],\n                        type=step_feedback[\"step_type\"],\n                        threadId=thread_id,\n                        parentId=step_feedback.get(\"step_parentid\"),\n                        streaming=step_feedback.get(\"step_streaming\", False),\n                        waitForAnswer=step_feedback.get(\"step_waitforanswer\"),\n                        isError=step_feedback.get(\"step_iserror\"),\n                        metadata=(\n                            step_feedback[\"step_metadata\"]\n                            if step_feedback.get(\"step_metadata\") is not None\n                            else {}\n                        ),\n                        tags=step_feedback.get(\"step_tags\"),\n                        input=(\n                            step_feedback.get(\"step_input\", \"\")\n                            if step_feedback.get(\"step_showinput\")\n                            not in [None, \"false\"]\n                            else \"\"\n                        ),\n                        output=step_feedback.get(\"step_output\", \"\"),\n                        createdAt=step_feedback.get(\"step_createdat\"),\n                        start=step_feedback.get(\"step_start\"),\n                        end=step_feedback.get(\"step_end\"),\n                        generation=step_feedback.get(\"step_generation\"),\n                        showInput=step_feedback.get(\"step_showinput\"),\n                        language=step_feedback.get(\"step_language\"),\n                        feedback=feedback,\n                    )\n                    # Append the step to the steps list of the corresponding ThreadDict\n                    thread_dicts[thread_id][\"steps\"].append(step_dict)\n\n        if isinstance(elements, list):\n            for element in elements:\n                thread_id = element[\"element_threadid\"]\n                if thread_id is not None:\n                    element_url: str | None = None\n                    object_key_val = element.get(\"element_objectkey\")\n                    if (\n                        self.storage_provider is not None\n                        and isinstance(object_key_val, str)\n                        and object_key_val.strip()\n                    ):\n                        try:\n                            element_url = await self.storage_provider.get_read_url(\n                                object_key=object_key_val,\n                            )\n                        except Exception as e:\n                            logger.warning(\n                                f\"Failed to get read URL for object_key '{object_key_val}': {e}. Falling back to stored URL.\"\n                            )\n                            element_url = element.get(\"element_url\")\n                    else:\n                        element_url = element.get(\"element_url\")\n                    element_dict = ElementDict(\n                        id=element[\"element_id\"],\n                        threadId=thread_id,\n                        type=element[\"element_type\"],\n                        chainlitKey=element.get(\"element_chainlitkey\"),\n                        url=element_url,\n                        objectKey=element.get(\"element_objectkey\"),\n                        name=element[\"element_name\"],\n                        display=element[\"element_display\"],\n                        size=element.get(\"element_size\"),\n                        language=element.get(\"element_language\"),\n                        autoPlay=element.get(\"element_autoPlay\"),\n                        playerConfig=element.get(\"element_playerconfig\"),\n                        page=element.get(\"element_page\"),\n                        props=element.get(\"props\", \"{}\"),\n                        forId=element.get(\"element_forid\"),\n                        mime=element.get(\"element_mime\"),\n                    )\n                    thread_dicts[thread_id][\"elements\"].append(element_dict)  # type: ignore\n\n        return list(thread_dicts.values())\n\n    async def get_favorite_steps(self, user_id: str) -> List[StepDict]:\n        if self.show_logger:\n            logger.info(f\"SQLAlchemy: get_favorite_steps, user_id={user_id}\")\n\n        query = \"\"\"\n                SELECT\n                    s.\"id\" AS step_id,\n                    s.\"name\" AS step_name,\n                    s.\"type\" AS step_type,\n                    s.\"threadId\" AS step_threadid,\n                    s.\"parentId\" AS step_parentid,\n                    s.\"streaming\" AS step_streaming,\n                    s.\"waitForAnswer\" AS step_waitforanswer,\n                    s.\"isError\" AS step_iserror,\n                    s.\"metadata\" AS step_metadata,\n                    s.\"tags\" AS step_tags,\n                    s.\"input\" AS step_input,\n                    s.\"output\" AS step_output,\n                    s.\"createdAt\" AS step_createdat,\n                    s.\"start\" AS step_start,\n                    s.\"end\" AS step_end,\n                    s.\"generation\" AS step_generation,\n                    s.\"showInput\" AS step_showinput,\n                    s.\"language\" AS step_language\n                FROM steps s\n                         JOIN threads t ON s.\"threadId\" = t.id\n                WHERE t.\"userId\" = :user_id\n                  AND s.\"metadata\" LIKE :favorite_pattern\n                ORDER BY s.\"createdAt\" DESC \\\n                \"\"\"\n\n        result = await self.execute_sql(\n            query, {\"user_id\": user_id, \"favorite_pattern\": '%\"favorite\": true%'}\n        )\n\n        steps = []\n        if isinstance(result, list):\n            for row in result:\n                metadata_raw = row[\"step_metadata\"]\n                meta_dict = {}\n                if isinstance(metadata_raw, str):\n                    try:\n                        meta_dict = json.loads(metadata_raw)\n                    except Exception:\n                        pass\n                elif isinstance(metadata_raw, dict):\n                    meta_dict = metadata_raw\n\n                if meta_dict.get(\"favorite\"):\n                    steps.append(\n                        StepDict(\n                            id=row[\"step_id\"],\n                            name=row[\"step_name\"],\n                            type=row[\"step_type\"],\n                            threadId=row[\"step_threadid\"],\n                            parentId=row[\"step_parentid\"],\n                            streaming=row.get(\"step_streaming\", False),\n                            waitForAnswer=row.get(\"step_waitforanswer\"),\n                            isError=row.get(\"step_iserror\"),\n                            metadata=meta_dict,\n                            tags=row.get(\"step_tags\"),\n                            input=(\n                                row.get(\"step_input\", \"\")\n                                if row.get(\"step_showinput\") not in [None, \"false\"]\n                                else \"\"\n                            ),\n                            output=row.get(\"step_output\", \"\"),\n                            createdAt=row.get(\"step_createdat\"),\n                            start=row.get(\"step_start\"),\n                            end=row.get(\"step_end\"),\n                            generation=row.get(\"step_generation\"),\n                            showInput=row.get(\"step_showinput\"),\n                            language=row.get(\"step_language\"),\n                            feedback=None,\n                        )\n                    )\n        return steps\n\n    async def close(self) -> None:\n        if self.storage_provider:\n            await self.storage_provider.close()\n        await self.engine.dispose()\n"
  },
  {
    "path": "backend/chainlit/data/storage_clients/__init__.py",
    "content": ""
  },
  {
    "path": "backend/chainlit/data/storage_clients/azure.py",
    "content": "from typing import TYPE_CHECKING, Any, Dict, Optional, Union\n\nfrom azure.storage.filedatalake import (\n    ContentSettings,\n    DataLakeFileClient,\n    DataLakeServiceClient,\n    FileSystemClient,\n)\n\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.logger import logger\n\nif TYPE_CHECKING:\n    from azure.core.credentials import (\n        AzureNamedKeyCredential,\n        AzureSasCredential,\n        TokenCredential,\n    )\n\n\nclass AzureStorageClient(BaseStorageClient):\n    \"\"\"\n    Class to enable Azure Data Lake Storage (ADLS) Gen2\n\n    parms:\n        account_url: \"https://<your_account>.dfs.core.windows.net\"\n        credential: Access credential (AzureKeyCredential)\n        sas_token: Optionally include SAS token to append to urls\n    \"\"\"\n\n    def __init__(\n        self,\n        account_url: str,\n        container: str,\n        credential: Optional[\n            Union[\n                str,\n                Dict[str, str],\n                \"AzureNamedKeyCredential\",\n                \"AzureSasCredential\",\n                \"TokenCredential\",\n            ]\n        ],\n        sas_token: Optional[str] = None,\n    ):\n        try:\n            self.data_lake_client = DataLakeServiceClient(\n                account_url=account_url, credential=credential\n            )\n            self.container_client: FileSystemClient = (\n                self.data_lake_client.get_file_system_client(file_system=container)\n            )\n            self.sas_token = sas_token\n            logger.info(\"AzureStorageClient initialized\")\n        except Exception as e:\n            logger.warning(f\"AzureStorageClient initialization error: {e}\")\n\n    async def upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        try:\n            file_client: DataLakeFileClient = self.container_client.get_file_client(\n                object_key\n            )\n            content_settings = ContentSettings(\n                content_type=mime, content_disposition=content_disposition\n            )\n            file_client.upload_data(\n                data, overwrite=overwrite, content_settings=content_settings\n            )\n            url = (\n                f\"{file_client.url}{self.sas_token}\"\n                if self.sas_token\n                else file_client.url\n            )\n            return {\"object_key\": object_key, \"url\": url}\n        except Exception as e:\n            logger.warning(f\"AzureStorageClient, upload_file error: {e}\")\n            return {}\n\n    async def close(self) -> None:\n        self.container_client.close()\n        self.data_lake_client.close()\n"
  },
  {
    "path": "backend/chainlit/data/storage_clients/azure_blob.py",
    "content": "from datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict, Union\n\nfrom azure.storage.blob import BlobSasPermissions, ContentSettings, generate_blob_sas\nfrom azure.storage.blob.aio import BlobServiceClient as AsyncBlobServiceClient\n\nfrom chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time\nfrom chainlit.logger import logger\n\n\nclass AzureBlobStorageClient(BaseStorageClient):\n    def __init__(self, container_name: str, storage_account: str, storage_key: str):\n        self.container_name = container_name\n        self.storage_account = storage_account\n        self.storage_key = storage_key\n        connection_string = (\n            f\"DefaultEndpointsProtocol=https;\"\n            f\"AccountName={storage_account};\"\n            f\"AccountKey={storage_key};\"\n            f\"EndpointSuffix=core.windows.net\"\n        )\n        self.service_client = AsyncBlobServiceClient.from_connection_string(\n            connection_string\n        )\n        self.container_client = self.service_client.get_container_client(\n            self.container_name\n        )\n        logger.info(\"AzureBlobStorageClient initialized\")\n\n    async def get_read_url(self, object_key: str) -> str:\n        if not self.storage_key:\n            raise Exception(\"Not using Azure Storage\")\n\n        sas_permissions = BlobSasPermissions(read=True)\n        start_time = datetime.now(tz=timezone.utc)\n        expiry_time = start_time + timedelta(seconds=storage_expiry_time)\n\n        sas_token = generate_blob_sas(\n            account_name=self.storage_account,\n            container_name=self.container_name,\n            blob_name=object_key,\n            account_key=self.storage_key,\n            permission=sas_permissions,\n            start=start_time,\n            expiry=expiry_time,\n        )\n\n        return f\"https://{self.storage_account}.blob.core.windows.net/{self.container_name}/{object_key}?{sas_token}\"\n\n    async def upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        try:\n            blob_client = self.container_client.get_blob_client(object_key)\n\n            if isinstance(data, str):\n                data = data.encode(\"utf-8\")\n\n            content_settings = ContentSettings(\n                content_type=mime, content_disposition=content_disposition\n            )\n\n            await blob_client.upload_blob(\n                data, overwrite=overwrite, content_settings=content_settings\n            )\n\n            properties = await blob_client.get_blob_properties()\n\n            return {\n                \"path\": object_key,\n                \"object_key\": object_key,\n                \"url\": await self.get_read_url(object_key),\n                \"size\": properties.size,\n                \"last_modified\": properties.last_modified,\n                \"etag\": properties.etag,\n                \"content_type\": properties.content_settings.content_type,\n            }\n\n        except Exception as e:\n            raise Exception(f\"Failed to upload file to Azure Blob Storage: {e!s}\")\n\n    async def delete_file(self, object_key: str) -> bool:\n        try:\n            blob_client = self.container_client.get_blob_client(blob=object_key)\n            await blob_client.delete_blob()\n            return True\n        except Exception as e:\n            logger.warning(f\"AzureBlobStorageClient, delete_file error: {e}\")\n            return False\n\n    async def close(self) -> None:\n        await self.container_client.close()\n        await self.service_client.close()\n"
  },
  {
    "path": "backend/chainlit/data/storage_clients/base.py",
    "content": "import os\nfrom abc import ABC, abstractmethod\nfrom typing import Any, Dict, Union\n\nstorage_expiry_time = int(os.getenv(\"STORAGE_EXPIRY_TIME\", 3600))\n\n\nclass BaseStorageClient(ABC):\n    \"\"\"Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc.\"\"\"\n\n    @abstractmethod\n    async def upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    async def delete_file(self, object_key: str) -> bool:\n        pass\n\n    @abstractmethod\n    async def get_read_url(self, object_key: str) -> str:\n        pass\n\n    @abstractmethod\n    async def close(self) -> None:\n        pass\n"
  },
  {
    "path": "backend/chainlit/data/storage_clients/gcs.py",
    "content": "from typing import Any, Dict, Optional, Union\n\nfrom google.auth import default\nfrom google.cloud import storage  # type: ignore\nfrom google.oauth2 import service_account\n\nfrom chainlit import make_async\nfrom chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time\nfrom chainlit.logger import logger\n\n\nclass GCSStorageClient(BaseStorageClient):\n    def __init__(\n        self,\n        bucket_name: str,\n        project_id: Optional[str] = None,\n        client_email: Optional[str] = None,\n        private_key: Optional[str] = None,\n    ):\n        if client_email and private_key and project_id:\n            # Go to IAM & Admin, click on Service Accounts, and generate a new JSON key\n            logger.info(\"Using Private Key from Environment Variable\")\n            credentials = service_account.Credentials.from_service_account_info(\n                {\n                    \"type\": \"service_account\",\n                    \"project_id\": project_id,\n                    \"private_key\": private_key,\n                    \"client_email\": client_email,\n                    \"token_uri\": \"https://oauth2.googleapis.com/token\",\n                }\n            )\n        else:\n            # Application Default Credentials (e.g. in Google Cloud Run)\n            logger.info(\"Using Application Default Credentials.\")\n            credentials, default_project_id = default()\n            if not project_id:\n                project_id = default_project_id\n\n        self.client = storage.Client(project=project_id, credentials=credentials)\n        self.bucket = self.client.bucket(bucket_name)\n        logger.info(\"GCSStorageClient initialized\")\n\n    def sync_get_read_url(self, object_key: str) -> str:\n        return self.bucket.blob(object_key).generate_signed_url(\n            version=\"v4\", expiration=storage_expiry_time, method=\"GET\"\n        )\n\n    async def get_read_url(self, object_key: str) -> str:\n        return await make_async(self.sync_get_read_url)(object_key)\n\n    def sync_upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n    ) -> Dict[str, Any]:\n        try:\n            blob = self.bucket.blob(object_key)\n\n            if not overwrite and blob.exists():\n                raise Exception(\n                    f\"File {object_key} already exists and overwrite is False\"\n                )\n\n            if isinstance(data, str):\n                data = data.encode(\"utf-8\")\n\n            blob.upload_from_string(data, content_type=mime)\n\n            # Return signed URL\n            return {\n                \"object_key\": object_key,\n                \"url\": self.sync_get_read_url(object_key),\n            }\n\n        except Exception as e:\n            raise Exception(f\"Failed to upload file to GCS: {e!s}\")\n\n    async def upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        return await make_async(self.sync_upload_file)(\n            object_key, data, mime, overwrite\n        )\n\n    def sync_delete_file(self, object_key: str) -> bool:\n        try:\n            self.bucket.blob(object_key).delete()\n            return True\n        except Exception as e:\n            logger.warning(f\"GCSStorageClient, delete_file error: {e}\")\n            return False\n\n    async def delete_file(self, object_key: str) -> bool:\n        return await make_async(self.sync_delete_file)(object_key)\n\n    async def close(self) -> None:\n        self.client.close()\n"
  },
  {
    "path": "backend/chainlit/data/storage_clients/s3.py",
    "content": "import os\nfrom typing import Any, Dict, Union\n\nimport boto3  # type: ignore\n\nfrom chainlit import make_async\nfrom chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time\nfrom chainlit.logger import logger\n\n\nclass S3StorageClient(BaseStorageClient):\n    \"\"\"\n    Class to enable Amazon S3 storage provider\n    \"\"\"\n\n    def __init__(self, bucket: str, **kwargs: Any):\n        try:\n            self.bucket = bucket\n            self.client = boto3.client(\"s3\", **kwargs)\n            logger.info(\"S3StorageClient initialized\")\n        except Exception as e:\n            logger.warning(f\"S3StorageClient initialization error: {e}\")\n\n    def sync_get_read_url(self, object_key: str) -> str:\n        try:\n            url = self.client.generate_presigned_url(\n                \"get_object\",\n                Params={\"Bucket\": self.bucket, \"Key\": object_key},\n                ExpiresIn=storage_expiry_time,\n            )\n            return url\n        except Exception as e:\n            logger.warning(f\"S3StorageClient, get_read_url error: {e}\")\n            return object_key\n\n    async def get_read_url(self, object_key: str) -> str:\n        return await make_async(self.sync_get_read_url)(object_key)\n\n    def sync_upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        try:\n            if content_disposition is not None:\n                self.client.put_object(\n                    Bucket=self.bucket,\n                    Key=object_key,\n                    Body=data,\n                    ContentType=mime,\n                    ContentDisposition=content_disposition,\n                )\n            else:\n                self.client.put_object(\n                    Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime\n                )\n            endpoint = os.environ.get(\"DEV_AWS_ENDPOINT\", \"amazonaws.com\")\n            url = f\"https://{self.bucket}.s3.{endpoint}/{object_key}\"\n            return {\"object_key\": object_key, \"url\": url}\n        except Exception as e:\n            logger.warning(f\"S3StorageClient, upload_file error: {e}\")\n            return {}\n\n    async def upload_file(\n        self,\n        object_key: str,\n        data: Union[bytes, str],\n        mime: str = \"application/octet-stream\",\n        overwrite: bool = True,\n        content_disposition: str | None = None,\n    ) -> Dict[str, Any]:\n        return await make_async(self.sync_upload_file)(\n            object_key, data, mime, overwrite, content_disposition\n        )\n\n    def sync_delete_file(self, object_key: str) -> bool:\n        try:\n            self.client.delete_object(Bucket=self.bucket, Key=object_key)\n            return True\n        except Exception as e:\n            logger.warning(f\"S3StorageClient, delete_file error: {e}\")\n            return False\n\n    async def delete_file(self, object_key: str) -> bool:\n        return await make_async(self.sync_delete_file)(object_key)\n\n    async def close(self) -> None:\n        await self.client.close()\n"
  },
  {
    "path": "backend/chainlit/data/utils.py",
    "content": "import functools\nfrom collections import deque\n\nfrom chainlit.context import context\nfrom chainlit.session import WebsocketSession\n\n\ndef queue_until_user_message():\n    def decorator(method):\n        @functools.wraps(method)\n        async def wrapper(self, *args, **kwargs):\n            if (\n                isinstance(context.session, WebsocketSession)\n                and not context.session.has_first_interaction\n            ):\n                # Queue the method invocation waiting for the first user message\n                queues = context.session.thread_queues\n                method_name = method.__name__\n                if method_name not in queues:\n                    queues[method_name] = deque()\n                queues[method_name].append((method, self, args, kwargs))\n\n            else:\n                # Otherwise, Execute the method immediately\n                return await method(self, *args, **kwargs)\n\n        return wrapper\n\n    return decorator\n"
  },
  {
    "path": "backend/chainlit/discord/__init__.py",
    "content": "import importlib.util\n\nif importlib.util.find_spec(\"discord\") is None:\n    raise ValueError(\n        \"The discord package is required to integrate Chainlit with a Discord app. Run `pip install discord --upgrade`\"\n    )\n"
  },
  {
    "path": "backend/chainlit/discord/app.py",
    "content": "import asyncio\nimport mimetypes\nimport re\nimport uuid\nfrom datetime import datetime\nfrom io import BytesIO\nfrom typing import TYPE_CHECKING, Dict, List, Optional, Union\n\nif TYPE_CHECKING:\n    from discord.abc import MessageableChannel\n\nimport discord\nimport filetype\nimport httpx\nfrom discord.ui import Button, View\n\nfrom chainlit.config import config\nfrom chainlit.context import ChainlitContext, HTTPSession, context, context_var\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import Element, ElementDict\nfrom chainlit.emitter import BaseChainlitEmitter\nfrom chainlit.logger import logger\nfrom chainlit.message import Message, StepDict\nfrom chainlit.types import Feedback\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.user_session import user_session\n\n\nclass FeedbackView(View):\n    def __init__(self, step_id: str):\n        super().__init__(timeout=None)\n        self.step_id = step_id\n\n    @discord.ui.button(label=\"👎\")\n    async def thumbs_down(self, interaction: discord.Interaction, button: Button):\n        if data_layer := get_data_layer():\n            try:\n                feedback = Feedback(forId=self.step_id, value=0)\n                await data_layer.upsert_feedback(feedback)\n            except Exception as e:\n                logger.error(f\"Error upserting feedback: {e}\")\n        if interaction.message:\n            await interaction.message.edit(view=None)\n            await interaction.message.add_reaction(\"👎\")\n\n    @discord.ui.button(label=\"👍\")\n    async def thumbs_up(self, interaction: discord.Interaction, button: Button):\n        if data_layer := get_data_layer():\n            try:\n                feedback = Feedback(forId=self.step_id, value=1)\n                await data_layer.upsert_feedback(feedback)\n            except Exception as e:\n                logger.error(f\"Error upserting feedback: {e}\")\n        if interaction.message:\n            await interaction.message.edit(view=None)\n            await interaction.message.add_reaction(\"👍\")\n\n\nclass DiscordEmitter(BaseChainlitEmitter):\n    def __init__(self, session: HTTPSession, channel: \"MessageableChannel\"):\n        super().__init__(session)\n        self.channel = channel\n\n    async def send_element(self, element_dict: ElementDict):\n        if element_dict.get(\"display\") != \"inline\":\n            return\n\n        persisted_file = self.session.files.get(element_dict.get(\"chainlitKey\") or \"\")\n        file: Optional[Union[BytesIO, str]] = None\n        mime: Optional[str] = None\n\n        if persisted_file:\n            file = str(persisted_file[\"path\"])\n            mime = element_dict.get(\"mime\")\n        elif file_url := element_dict.get(\"url\"):\n            async with httpx.AsyncClient() as client:\n                response = await client.get(file_url)\n                if response.status_code == 200:\n                    file = BytesIO(response.content)\n                    mime = filetype.guess_mime(file)\n\n        if not file:\n            return\n\n        element_name: str = element_dict.get(\"name\", \"Untitled\")\n\n        if mime:\n            file_extension = mimetypes.guess_extension(mime)\n            if file_extension:\n                element_name += file_extension\n\n        file_obj = discord.File(file, filename=element_name)\n        await self.channel.send(file=file_obj)\n\n    async def send_step(self, step_dict: StepDict):\n        if not step_dict[\"type\"] == \"assistant_message\":\n            return\n\n        step_type = step_dict.get(\"type\")\n        is_message = step_type in [\n            \"user_message\",\n            \"assistant_message\",\n        ]\n        is_empty_output = not step_dict.get(\"output\")\n\n        if is_empty_output or not is_message:\n            return\n        else:\n            enable_feedback = get_data_layer()\n            message = await self.channel.send(step_dict[\"output\"])\n\n            if enable_feedback:\n                current_run = context.current_run\n                scorable_id = current_run.id if current_run else step_dict.get(\"id\")\n                if not scorable_id:\n                    return\n                view = FeedbackView(scorable_id)\n                await message.edit(view=view)\n\n    async def update_step(self, step_dict: StepDict):\n        if not step_dict[\"type\"] == \"assistant_message\":\n            return\n\n        await self.send_step(step_dict)\n\n\nintents = discord.Intents.default()\nintents.message_content = True\n\nclient = discord.Client(intents=intents)\n\n\ndef init_discord_context(\n    session: HTTPSession,\n    channel: \"MessageableChannel\",\n    message: discord.Message,\n) -> ChainlitContext:\n    emitter = DiscordEmitter(session=session, channel=channel)\n    context = ChainlitContext(session=session, emitter=emitter)\n    context_var.set(context)\n    user_session.set(\"discord_message\", message)\n    user_session.set(\"discord_channel\", channel)\n    return context\n\n\nusers_by_discord_id: Dict[int, Union[User, PersistedUser]] = {}\n\nUSER_PREFIX = \"discord_\"\n\n\nasync def get_user(discord_user: Union[discord.User, discord.Member]):\n    if discord_user.id in users_by_discord_id:\n        return users_by_discord_id[discord_user.id]\n\n    metadata = {\n        \"name\": discord_user.name,\n        \"id\": discord_user.id,\n    }\n    user = User(identifier=USER_PREFIX + str(discord_user.name), metadata=metadata)\n\n    users_by_discord_id[discord_user.id] = user\n\n    if data_layer := get_data_layer():\n        try:\n            persisted_user = await data_layer.create_user(user)\n            if persisted_user:\n                users_by_discord_id[discord_user.id] = persisted_user\n        except Exception as e:\n            logger.error(f\"Error creating user: {e}\")\n\n    return users_by_discord_id[discord_user.id]\n\n\nasync def download_discord_file(url: str):\n    async with httpx.AsyncClient() as client:\n        response = await client.get(url)\n        if response.status_code == 200:\n            return response.content\n        else:\n            return None\n\n\nasync def download_discord_files(\n    session: HTTPSession, attachments: List[discord.Attachment]\n):\n    download_coros = [\n        download_discord_file(attachment.url) for attachment in attachments\n    ]\n    file_bytes_list = await asyncio.gather(*download_coros)\n    file_refs = []\n    for idx, file_bytes in enumerate(file_bytes_list):\n        if file_bytes:\n            name = attachments[idx].filename\n            mime_type = attachments[idx].content_type or \"application/octet-stream\"\n            file_ref = await session.persist_file(\n                name=name, mime=mime_type, content=file_bytes\n            )\n            file_refs.append(file_ref)\n\n    files_dicts = [\n        session.files[file[\"id\"]] for file in file_refs if file[\"id\"] in session.files\n    ]\n\n    elements = [\n        Element.from_dict(\n            {\n                \"id\": file[\"id\"],\n                \"name\": file[\"name\"],\n                \"path\": str(file[\"path\"]),\n                \"chainlitKey\": file[\"id\"],\n                \"display\": \"inline\",\n                \"type\": Element.infer_type_from_mime(file[\"type\"]),\n            }\n        )\n        for file in files_dicts\n    ]\n\n    return elements\n\n\ndef clean_content(message: discord.Message):\n    if not client.user:\n        return message.content\n\n    # Regex to find mentions of the bot\n    bot_mention = f\"<@!?{client.user.id}>\"\n    # Replace the bot's mention with nothing\n    return re.sub(bot_mention, \"\", message.content).strip()\n\n\nasync def process_discord_message(\n    message: discord.Message,\n    thread_id: str,\n    thread_name: str,\n    channel: \"MessageableChannel\",\n    bind_thread_to_user=False,\n):\n    user = await get_user(message.author)\n\n    text = clean_content(message)\n    discord_files = message.attachments\n\n    session_id = str(uuid.uuid4())\n    session = HTTPSession(\n        id=session_id,\n        thread_id=thread_id,\n        user=user,\n        client_type=\"discord\",\n    )\n\n    ctx = init_discord_context(\n        session=session,\n        channel=channel,\n        message=message,\n    )\n\n    file_elements = await download_discord_files(session, discord_files)\n\n    if on_chat_start := config.code.on_chat_start:\n        await on_chat_start()\n\n    msg = Message(\n        content=text,\n        elements=file_elements,\n        type=\"user_message\",\n        author=user.metadata.get(\"name\"),\n    )\n\n    await msg.send()\n\n    if on_message := config.code.on_message:\n        async with channel.typing():\n            await on_message(msg)\n\n    if on_chat_end := config.code.on_chat_end:\n        await on_chat_end()\n\n    if data_layer := get_data_layer():\n        user_id = None\n        if isinstance(user, PersistedUser):\n            user_id = user.id if bind_thread_to_user else None\n\n        try:\n            await data_layer.update_thread(\n                thread_id=thread_id,\n                name=thread_name,\n                metadata=ctx.session.to_persistable(),\n                user_id=user_id,\n            )\n        except Exception as e:\n            logger.error(f\"Error updating thread: {e}\")\n\n    await ctx.session.delete()\n\n\n@client.event\nasync def on_ready():\n    logger.info(f\"Logged in as {client.user}\")\n\n\n@client.event\nasync def on_message(message: discord.Message):\n    if not client.user or message.author == client.user:\n        return\n\n    is_dm = isinstance(message.channel, discord.DMChannel)\n    if not client.user.mentioned_in(message) and not is_dm:\n        return\n\n    thread_name: str = \"\"\n    thread_id: str = \"\"\n    bind_thread_to_user = False\n    channel = message.channel\n\n    if isinstance(message.channel, discord.Thread):\n        thread_name = f\"{message.channel.name}\"\n        thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id)))\n    elif isinstance(message.channel, discord.ForumChannel):\n        thread_name = f\"{message.channel.name}\"\n        thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id)))\n    elif isinstance(message.channel, discord.DMChannel):\n        thread_id = str(\n            uuid.uuid5(\n                uuid.NAMESPACE_DNS,\n                str(channel.id) + datetime.today().strftime(\"%Y-%m-%d\"),\n            )\n        )\n        thread_name = (\n            f\"{message.author} Discord DM {datetime.today().strftime('%Y-%m-%d')}\"\n        )\n        bind_thread_to_user = True\n    elif isinstance(message.channel, discord.GroupChannel):\n        thread_id = str(\n            uuid.uuid5(\n                uuid.NAMESPACE_DNS,\n                str(channel.id) + datetime.today().strftime(\"%Y-%m-%d\"),\n            )\n        )\n        thread_name = f\"{message.channel.name}\"\n    elif isinstance(message.channel, discord.TextChannel):\n        # Discord limits thread names to 100 characters and does not create\n        # threads from empty messages.\n        thread_id = str(\n            uuid.uuid5(\n                uuid.NAMESPACE_DNS,\n                str(channel.id) + datetime.today().strftime(\"%Y-%m-%d\"),\n            )\n        )\n        discord_thread_name = clean_content(message)[:100] or \"Untitled\"\n        channel = await message.channel.create_thread(\n            name=discord_thread_name, message=message\n        )\n        thread_name = f\"{channel.name}\"\n    else:\n        logger.warning(f\"Unsupported channel type: {message.channel.type}\")\n        return\n\n    await process_discord_message(\n        message=message,\n        thread_id=thread_id,\n        thread_name=thread_name,\n        channel=channel,\n        bind_thread_to_user=bind_thread_to_user,\n    )\n"
  },
  {
    "path": "backend/chainlit/element.py",
    "content": "import json\nimport mimetypes\nimport uuid\nfrom enum import Enum\nfrom io import BytesIO\nfrom typing import (\n    Any,\n    ClassVar,\n    Dict,\n    List,\n    Literal,\n    Optional,\n    TypedDict,\n    TypeVar,\n    Union,\n)\n\nimport filetype\nfrom pydantic import Field\nfrom pydantic.dataclasses import dataclass\nfrom syncer import asyncio\n\nfrom chainlit.context import context\nfrom chainlit.data import get_data_layer\nfrom chainlit.logger import logger\n\nmime_types = {\n    \"text\": \"text/plain\",\n    \"tasklist\": \"application/json\",\n    \"plotly\": \"application/json\",\n}\n\nElementType = Literal[\n    \"image\",\n    \"text\",\n    \"pdf\",\n    \"tasklist\",\n    \"audio\",\n    \"video\",\n    \"file\",\n    \"plotly\",\n    \"dataframe\",\n    \"custom\",\n]\nElementDisplay = Literal[\"inline\", \"side\", \"page\"]\nElementSize = Literal[\"small\", \"medium\", \"large\"]\n\n\nclass ElementDict(TypedDict, total=False):\n    id: str\n    threadId: Optional[str]\n    type: ElementType\n    chainlitKey: Optional[str]\n    path: Optional[str]\n    url: Optional[str]\n    objectKey: Optional[str]\n    name: str\n    display: ElementDisplay\n    size: Optional[ElementSize]\n    language: Optional[str]\n    page: Optional[int]\n    props: Optional[Dict]\n    autoPlay: Optional[bool]\n    playerConfig: Optional[dict]\n    forId: Optional[str]\n    mime: Optional[str]\n\n\n@dataclass\nclass Element:\n    # Thread id\n    thread_id: str = Field(default_factory=lambda: context.session.thread_id)\n    # The type of the element. This will be used to determine how to display the element in the UI.\n    type: ClassVar[ElementType]\n    # Name of the element, this will be used to reference the element in the UI.\n    name: str = \"\"\n    # The ID of the element. This is set automatically when the element is sent to the UI.\n    id: str = Field(default_factory=lambda: str(uuid.uuid4()))\n    # The key of the element hosted on Chainlit.\n    chainlit_key: Optional[str] = None\n    # The URL of the element if already hosted somewhere else.\n    url: Optional[str] = None\n    # The S3 object key.\n    object_key: Optional[str] = None\n    # The local path of the element.\n    path: Optional[str] = None\n    # The byte content of the element.\n    content: Optional[Union[bytes, str]] = None\n    # Controls how the image element should be displayed in the UI. Choices are “side” (default), “inline”, or “page”.\n    display: ElementDisplay = Field(default=\"inline\")\n    # Controls element size\n    size: Optional[ElementSize] = None\n    # The ID of the message this element is associated with.\n    for_id: Optional[str] = None\n    # The language, if relevant\n    language: Optional[str] = None\n    # Mime type, inferred based on content if not provided\n    mime: Optional[str] = None\n\n    def __post_init__(self) -> None:\n        self.persisted = False\n        self.updatable = False\n\n        if not self.url and not self.path and not self.content:\n            raise ValueError(\"Must provide url, path or content to instantiate element\")\n\n    def to_dict(self) -> ElementDict:\n        _dict = ElementDict(\n            {\n                \"id\": self.id,\n                \"threadId\": self.thread_id,\n                \"type\": self.type,\n                \"url\": self.url,\n                \"chainlitKey\": self.chainlit_key,\n                \"name\": self.name,\n                \"display\": self.display,\n                \"objectKey\": getattr(self, \"object_key\", None),\n                \"size\": getattr(self, \"size\", None),\n                \"props\": getattr(self, \"props\", None),\n                \"page\": getattr(self, \"page\", None),\n                \"autoPlay\": getattr(self, \"auto_play\", None),\n                \"playerConfig\": getattr(self, \"player_config\", None),\n                \"language\": getattr(self, \"language\", None),\n                \"forId\": getattr(self, \"for_id\", None),\n                \"mime\": getattr(self, \"mime\", None),\n            }\n        )\n        return _dict\n\n    @classmethod\n    def from_dict(cls, e_dict: ElementDict):\n        \"\"\"\n        Create an Element instance from a dictionary representation.\n\n        Args:\n            _dict (ElementDict): Dictionary containing element data\n\n        Returns:\n            Element: An instance of the appropriate Element subclass\n        \"\"\"\n        element_id = e_dict.get(\"id\", str(uuid.uuid4()))\n        for_id = e_dict.get(\"forId\")\n        name = e_dict.get(\"name\", \"\")\n        type = e_dict.get(\"type\", \"file\")\n        path = str(e_dict.get(\"path\")) if e_dict.get(\"path\") else None\n        url = str(e_dict.get(\"url\")) if e_dict.get(\"url\") else None\n        content = str(e_dict.get(\"content\")) if e_dict.get(\"content\") else None\n        object_key = e_dict.get(\"objectKey\")\n        chainlit_key = e_dict.get(\"chainlitKey\")\n        display = e_dict.get(\"display\", \"inline\")\n        mime_type = e_dict.get(\"mime\", \"\")\n\n        # Common parameters for all element types\n        common_params = {\n            \"id\": element_id,\n            \"for_id\": for_id,\n            \"name\": name,\n            \"content\": content,\n            \"path\": path,\n            \"url\": url,\n            \"object_key\": object_key,\n            \"chainlit_key\": chainlit_key,\n            \"display\": display,\n            \"mime\": mime_type,\n        }\n\n        if type == \"image\":\n            return Image(size=\"medium\", **common_params)  # type: ignore[arg-type]\n\n        elif type == \"audio\":\n            return Audio(auto_play=e_dict.get(\"autoPlay\", False), **common_params)  # type: ignore[arg-type]\n\n        elif type == \"video\":\n            return Video(\n                player_config=e_dict.get(\"playerConfig\"),\n                **common_params,  # type: ignore[arg-type]\n            )\n\n        elif type == \"plotly\":\n            return Plotly(size=e_dict.get(\"size\", \"medium\"), **common_params)  # type: ignore[arg-type]\n\n        elif type == \"custom\":\n            return CustomElement(props=e_dict.get(\"props\", {}), **common_params)  # type: ignore[arg-type]\n        else:\n            # Default to File for any other type\n            return File(**common_params)  # type: ignore[arg-type]\n\n    @classmethod\n    def infer_type_from_mime(cls, mime_type: str):\n        \"\"\"Infer the element type from a mime type. Useful to know which element to instantiate from a file upload.\"\"\"\n        if \"image\" in mime_type:\n            return \"image\"\n\n        elif mime_type == \"application/pdf\":\n            return \"pdf\"\n\n        elif \"audio\" in mime_type:\n            return \"audio\"\n\n        elif \"video\" in mime_type:\n            return \"video\"\n\n        else:\n            return \"file\"\n\n    async def _create(self, persist=True) -> bool:\n        if self.persisted and not self.updatable:\n            return True\n\n        if (data_layer := get_data_layer()) and persist:\n            try:\n                asyncio.create_task(data_layer.create_element(self))\n            except Exception as e:\n                logger.error(f\"Failed to create element: {e!s}\")\n        if not self.url and (not self.chainlit_key or self.updatable):\n            file_dict = await context.session.persist_file(\n                name=self.name,\n                path=self.path,\n                content=self.content,\n                mime=self.mime or \"\",\n            )\n            self.chainlit_key = file_dict[\"id\"]\n\n        self.persisted = True\n\n        return True\n\n    async def remove(self):\n        data_layer = get_data_layer()\n        if data_layer:\n            await data_layer.delete_element(self.id, self.thread_id)\n        await context.emitter.emit(\"remove_element\", {\"id\": self.id})\n\n    async def send(self, for_id: str, persist=True):\n        self.for_id = for_id\n\n        if not self.mime:\n            if self.type in mime_types:\n                self.mime = mime_types[self.type]\n            elif self.path or isinstance(self.content, (bytes, bytearray)):\n                file_type = filetype.guess(self.path or self.content)\n                if file_type:\n                    self.mime = file_type.mime\n            elif self.url:\n                self.mime = mimetypes.guess_type(self.url)[0]\n\n        await self._create(persist=persist)\n\n        if not self.url and not self.chainlit_key:\n            raise ValueError(\"Must provide url or chainlit key to send element\")\n\n        await context.emitter.send_element(self.to_dict())\n\n\nElementBased = TypeVar(\"ElementBased\", bound=Element)\n\n\n@dataclass\nclass Image(Element):\n    type: ClassVar[ElementType] = \"image\"\n\n    size: ElementSize = \"medium\"\n\n\n@dataclass\nclass Text(Element):\n    \"\"\"Useful to send a text (not a message) to the UI.\"\"\"\n\n    type: ClassVar[ElementType] = \"text\"\n    language: Optional[str] = None\n\n\n@dataclass\nclass Pdf(Element):\n    \"\"\"Useful to send a pdf to the UI.\"\"\"\n\n    mime: str = \"application/pdf\"\n    page: Optional[int] = None\n    type: ClassVar[ElementType] = \"pdf\"\n\n\n@dataclass\nclass Pyplot(Element):\n    \"\"\"Useful to send a pyplot to the UI.\"\"\"\n\n    # We reuse the frontend image element to display the chart\n    type: ClassVar[ElementType] = \"image\"\n\n    size: ElementSize = \"medium\"\n    # The type is set to Any because the figure is not serializable\n    # and its actual type is checked in __post_init__.\n    figure: Any = None\n\n    def __post_init__(self) -> None:\n        from matplotlib.figure import Figure\n\n        if not isinstance(self.figure, Figure):\n            raise TypeError(\"figure must be a matplotlib.figure.Figure\")\n\n        image = BytesIO()\n        self.figure.savefig(\n            image, dpi=200, bbox_inches=\"tight\", backend=\"Agg\", format=\"png\"\n        )\n        self.content = image.getvalue()\n\n        super().__post_init__()\n\n\nclass TaskStatus(Enum):\n    READY = \"ready\"\n    RUNNING = \"running\"\n    FAILED = \"failed\"\n    DONE = \"done\"\n\n\n@dataclass\nclass Task:\n    title: str\n    status: TaskStatus = TaskStatus.READY\n    forId: Optional[str] = None\n\n    def __init__(\n        self,\n        title: str,\n        status: TaskStatus = TaskStatus.READY,\n        forId: Optional[str] = None,\n    ):\n        self.title = title\n        self.status = status\n        self.forId = forId\n\n\n@dataclass\nclass TaskList(Element):\n    type: ClassVar[ElementType] = \"tasklist\"\n    tasks: List[Task] = Field(default_factory=list, exclude=True)\n    status: str = \"Ready\"\n    name: str = \"tasklist\"\n    content: str = \"dummy content to pass validation\"\n\n    def __post_init__(self) -> None:\n        super().__post_init__()\n        self.updatable = True\n\n    async def add_task(self, task: Task):\n        self.tasks.append(task)\n\n    async def update(self):\n        await self.send()\n\n    async def send(self):\n        await self.preprocess_content()\n        await super().send(for_id=\"\")\n\n    async def preprocess_content(self):\n        # serialize enum\n        tasks = [\n            {\"title\": task.title, \"status\": task.status.value, \"forId\": task.forId}\n            for task in self.tasks\n        ]\n\n        # store stringified json in content so that it's correctly stored in the database\n        self.content = json.dumps(\n            {\n                \"status\": self.status,\n                \"tasks\": tasks,\n            },\n            indent=4,\n            ensure_ascii=False,\n        )\n\n\n@dataclass\nclass Audio(Element):\n    type: ClassVar[ElementType] = \"audio\"\n    auto_play: bool = False\n\n\n@dataclass\nclass Video(Element):\n    type: ClassVar[ElementType] = \"video\"\n\n    size: ElementSize = \"medium\"\n    # Override settings for each type of player in ReactPlayer\n    # https://github.com/cookpete/react-player?tab=readme-ov-file#config-prop\n    player_config: Optional[dict] = None\n\n\n@dataclass\nclass File(Element):\n    type: ClassVar[ElementType] = \"file\"\n\n\n@dataclass\nclass Plotly(Element):\n    \"\"\"Useful to send a plotly to the UI.\"\"\"\n\n    type: ClassVar[ElementType] = \"plotly\"\n\n    size: ElementSize = \"medium\"\n    # The type is set to Any because the figure is not serializable\n    # and its actual type is checked in __post_init__.\n    figure: Any = None\n    content: str = \"\"\n\n    def __post_init__(self) -> None:\n        from plotly import graph_objects as go, io as pio\n\n        if not isinstance(self.figure, go.Figure):\n            raise TypeError(\"figure must be a plotly.graph_objects.Figure\")\n\n        self.figure.layout.autosize = True\n        self.figure.layout.width = None\n        self.figure.layout.height = None\n        self.content = pio.to_json(self.figure, validate=True)\n        self.mime = \"application/json\"\n\n        super().__post_init__()\n\n\n@dataclass\nclass Dataframe(Element):\n    \"\"\"Useful to send a pandas DataFrame to the UI.\"\"\"\n\n    type: ClassVar[ElementType] = \"dataframe\"\n    size: ElementSize = \"large\"\n    data: Any = None  # The type is Any because it is checked in __post_init__.\n\n    def __post_init__(self) -> None:\n        \"\"\"Ensures the data is a pandas DataFrame and converts it to JSON.\"\"\"\n        from pandas import DataFrame\n\n        if not isinstance(self.data, DataFrame):\n            raise TypeError(\"data must be a pandas.DataFrame\")\n\n        self.content = self.data.to_json(orient=\"split\", date_format=\"iso\")\n        super().__post_init__()\n\n\n@dataclass\nclass CustomElement(Element):\n    \"\"\"Useful to send a custom element to the UI.\"\"\"\n\n    type: ClassVar[ElementType] = \"custom\"\n    mime: str = \"application/json\"\n    props: Dict = Field(default_factory=dict)\n\n    def __post_init__(self) -> None:\n        self.content = json.dumps(self.props)\n        super().__post_init__()\n        self.updatable = True\n\n    async def update(self):\n        await super().send(self.for_id)\n"
  },
  {
    "path": "backend/chainlit/emitter.py",
    "content": "import asyncio\nimport uuid\nfrom typing import Any, Dict, List, Literal, Optional, Union, cast, get_args\n\nfrom socketio.exceptions import TimeoutError\n\nfrom chainlit.chat_context import chat_context\nfrom chainlit.config import config\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import Element, ElementDict, File\nfrom chainlit.logger import logger\nfrom chainlit.message import Message\nfrom chainlit.mode import Mode\nfrom chainlit.session import BaseSession, WebsocketSession\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    AskActionResponse,\n    AskElementResponse,\n    AskFileSpec,\n    AskSpec,\n    CommandDict,\n    FileDict,\n    FileReference,\n    MessagePayload,\n    OutputAudioChunk,\n    ThreadDict,\n    ToastType,\n)\nfrom chainlit.user import PersistedUser\nfrom chainlit.utils import utc_now\n\n\nclass BaseChainlitEmitter:\n    \"\"\"\n    Chainlit Emitter Stub class. This class is used for testing purposes.\n    It stubs the ChainlitEmitter class and does nothing on function calls.\n    \"\"\"\n\n    session: BaseSession\n    enabled: bool = True\n\n    def __init__(self, session: BaseSession) -> None:\n        \"\"\"Initialize with the user session.\"\"\"\n        self.session = session\n\n    async def emit(self, event: str, data: Any):\n        \"\"\"Stub method to get the 'emit' property from the session.\"\"\"\n        pass\n\n    async def emit_call(self):\n        \"\"\"Stub method to get the 'emit_call' property from the session.\"\"\"\n        pass\n\n    async def resume_thread(self, thread_dict: ThreadDict):\n        \"\"\"Stub method to resume a thread.\"\"\"\n        pass\n\n    async def send_resume_thread_error(self, error: str):\n        \"\"\"Stub method to send a resume thread error.\"\"\"\n        pass\n\n    async def send_element(self, element_dict: ElementDict):\n        \"\"\"Stub method to send an element to the UI.\"\"\"\n        pass\n\n    async def update_audio_connection(self, state: Literal[\"on\", \"off\"]):\n        \"\"\"Audio connection signaling.\"\"\"\n        pass\n\n    async def send_audio_chunk(self, chunk: OutputAudioChunk):\n        \"\"\"Stub method to send an audio chunk to the UI.\"\"\"\n        pass\n\n    async def send_audio_interrupt(self):\n        \"\"\"Stub method to interrupt the current audio response.\"\"\"\n        pass\n\n    async def send_step(self, step_dict: StepDict):\n        \"\"\"Stub method to send a message to the UI.\"\"\"\n        pass\n\n    async def update_step(self, step_dict: StepDict):\n        \"\"\"Stub method to update a message in the UI.\"\"\"\n        pass\n\n    async def delete_step(self, step_dict: StepDict):\n        \"\"\"Stub method to delete a message in the UI.\"\"\"\n        pass\n\n    def send_timeout(self, event: Literal[\"ask_timeout\", \"call_fn_timeout\"]):\n        \"\"\"Stub method to send a timeout to the UI.\"\"\"\n        pass\n\n    def clear(self, event: Literal[\"clear_ask\", \"clear_call_fn\"]):\n        pass\n\n    async def init_thread(self, interaction: str):\n        pass\n\n    async def process_message(self, payload: MessagePayload) -> Message:\n        \"\"\"Stub method to process user message.\"\"\"\n        return Message(content=\"\")\n\n    async def send_ask_user(\n        self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False\n    ) -> Optional[\n        Union[\"StepDict\", \"AskActionResponse\", \"AskElementResponse\", List[\"FileDict\"]]\n    ]:\n        \"\"\"Stub method to send a prompt to the UI and wait for a response.\"\"\"\n        pass\n\n    async def send_call_fn(\n        self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False\n    ) -> Optional[Dict[str, Any]]:\n        \"\"\"Stub method to send a call function event to the copilot and wait for a response.\"\"\"\n        pass\n\n    async def update_token_count(self, count: int):\n        \"\"\"Stub method to update the token count for the UI.\"\"\"\n        pass\n\n    async def task_start(self):\n        \"\"\"Stub method to send a task start signal to the UI.\"\"\"\n        pass\n\n    async def task_end(self):\n        \"\"\"Stub method to send a task end signal to the UI.\"\"\"\n        pass\n\n    async def stream_start(self, step_dict: StepDict):\n        \"\"\"Stub method to send a stream start signal to the UI.\"\"\"\n        pass\n\n    async def send_token(self, id: str, token: str, is_sequence=False, is_input=False):\n        \"\"\"Stub method to send a message token to the UI.\"\"\"\n        pass\n\n    async def set_chat_settings(self, settings: dict):\n        \"\"\"Stub method to set chat settings.\"\"\"\n        pass\n\n    async def set_commands(self, commands: List[CommandDict]):\n        \"\"\"Stub method to send the available commands to the UI.\"\"\"\n        pass\n\n    async def set_modes(self, modes: List[Mode]):\n        \"\"\"Stub method to send the available modes to the UI.\"\"\"\n        pass\n\n    async def send_window_message(self, data: Any):\n        \"\"\"Stub method to send custom data to the host window.\"\"\"\n        pass\n\n    def send_toast(self, message: str, type: Optional[ToastType] = \"info\"):\n        \"\"\"Stub method to send a toast message to the UI.\"\"\"\n        pass\n\n    async def set_favorites(self, steps: List[StepDict]):\n        \"\"\"Stub method to send the favorite messages to the UI.\"\"\"\n        pass\n\n\nclass ChainlitEmitter(BaseChainlitEmitter):\n    \"\"\"\n    Chainlit Emitter class. The Emitter is not directly exposed to the developer.\n    Instead, the developer interacts with the Emitter through the methods and classes exposed in the __init__ file.\n    \"\"\"\n\n    session: WebsocketSession\n\n    def __init__(self, session: WebsocketSession) -> None:\n        \"\"\"Initialize with the user session.\"\"\"\n        self.session = session\n\n    def _get_session_property(self, property_name: str, raise_error=True):\n        \"\"\"Helper method to get a property from the session.\"\"\"\n        if not hasattr(self, \"session\") or not hasattr(self.session, property_name):\n            if raise_error:\n                raise ValueError(f\"Session does not have property '{property_name}'\")\n            else:\n                return None\n        return getattr(self.session, property_name)\n\n    @property\n    def emit(self):\n        \"\"\"Get the 'emit' property from the session.\"\"\"\n\n        return self._get_session_property(\"emit\")\n\n    @property\n    def emit_call(self):\n        \"\"\"Get the 'emit_call' property from the session.\"\"\"\n        return self._get_session_property(\"emit_call\")\n\n    def resume_thread(self, thread_dict: ThreadDict):\n        \"\"\"Send a thread to the UI to resume it\"\"\"\n        return self.emit(\"resume_thread\", thread_dict)\n\n    def send_resume_thread_error(self, error: str):\n        \"\"\"Send a thread resume error to the UI\"\"\"\n        return self.emit(\"resume_thread_error\", error)\n\n    async def update_audio_connection(self, state: Literal[\"on\", \"off\"]):\n        \"\"\"Audio connection signaling.\"\"\"\n        await self.emit(\"audio_connection\", state)\n\n    async def send_audio_chunk(self, chunk: OutputAudioChunk):\n        \"\"\"Send an audio chunk to the UI.\"\"\"\n        await self.emit(\"audio_chunk\", chunk)\n\n    async def send_audio_interrupt(self):\n        \"\"\"Method to interrupt the current audio response.\"\"\"\n        await self.emit(\"audio_interrupt\", {})\n\n    async def send_element(self, element_dict: ElementDict):\n        \"\"\"Stub method to send an element to the UI.\"\"\"\n        await self.emit(\"element\", element_dict)\n\n    def send_step(self, step_dict: StepDict):\n        \"\"\"Send a message to the UI.\"\"\"\n        return self.emit(\"new_message\", step_dict)\n\n    def update_step(self, step_dict: StepDict):\n        \"\"\"Update a message in the UI.\"\"\"\n        return self.emit(\"update_message\", step_dict)\n\n    def delete_step(self, step_dict: StepDict):\n        \"\"\"Delete a message in the UI.\"\"\"\n        return self.emit(\"delete_message\", step_dict)\n\n    def send_timeout(self, event: Literal[\"ask_timeout\", \"call_fn_timeout\"]):\n        return self.emit(event, {})\n\n    def clear(self, event: Literal[\"clear_ask\", \"clear_call_fn\"]):\n        return self.emit(event, {})\n\n    async def flush_thread_queues(self, interaction: str):\n        if data_layer := get_data_layer():\n            if isinstance(self.session.user, PersistedUser):\n                user_id = self.session.user.id\n            else:\n                user_id = None\n            try:\n                should_tag_thread = (\n                    self.session.chat_profile and config.features.auto_tag_thread\n                )\n                tags = [self.session.chat_profile] if should_tag_thread else None\n                await data_layer.update_thread(\n                    thread_id=self.session.thread_id,\n                    name=interaction,\n                    user_id=user_id,\n                    tags=tags,\n                )\n            except Exception as e:\n                logger.error(f\"Error updating thread: {e}\")\n            asyncio.create_task(self.session.flush_method_queue())\n\n    async def init_thread(self, interaction: str):\n        await self.flush_thread_queues(interaction)\n        await self.emit(\n            \"first_interaction\",\n            {\n                \"interaction\": interaction,\n                \"thread_id\": self.session.thread_id,\n            },\n        )\n\n    async def process_message(self, payload: MessagePayload):\n        step_dict = payload[\"message\"]\n        file_refs = payload.get(\"fileReferences\")\n        # UUID generated by the frontend should use v4\n        assert uuid.UUID(step_dict[\"id\"]).version == 4\n\n        message = Message.from_dict(step_dict)\n        # Overwrite the created_at timestamp with the current time\n        message.created_at = utc_now()\n        chat_context.add(message)\n\n        asyncio.create_task(message._create())\n\n        if not self.session.has_first_interaction:\n            self.session.has_first_interaction = True\n            asyncio.create_task(self.init_thread(message.content))\n\n        if file_refs:\n            files = [\n                self.session.files[file[\"id\"]]\n                for file in file_refs\n                if file[\"id\"] in self.session.files\n            ]\n\n            elements = [\n                Element.from_dict(\n                    {\n                        \"id\": file[\"id\"],\n                        \"name\": file[\"name\"],\n                        \"path\": str(file[\"path\"]),\n                        \"chainlitKey\": file[\"id\"],\n                        \"display\": \"inline\",\n                        \"type\": Element.infer_type_from_mime(file[\"type\"]),\n                        \"mime\": file[\"type\"],\n                    }\n                )\n                for file in files\n            ]\n\n            message.elements = elements\n\n            async def send_elements():\n                for element in message.elements:\n                    await element.send(for_id=message.id)\n\n            asyncio.create_task(send_elements())\n\n        return message\n\n    async def send_ask_user(\n        self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False\n    ):\n        \"\"\"Send a prompt to the UI and wait for a response.\"\"\"\n        parent_id = str(step_dict[\"parentId\"])\n        try:\n            if spec.type == \"file\":\n                self.session.files_spec[parent_id] = cast(AskFileSpec, spec)\n\n            # Send the prompt to the UI\n            user_res = await self.emit_call(\n                \"ask\", {\"msg\": step_dict, \"spec\": spec.to_dict()}, spec.timeout\n            )  # type: Optional[Union[\"StepDict\", \"AskActionResponse\", \"AskElementResponse\", List[\"FileReference\"]]]\n\n            # End the task temporarily so that the User can answer the prompt\n            await self.task_end()\n\n            final_res: Optional[\n                Union[StepDict, AskActionResponse, AskElementResponse, List[FileDict]]\n            ] = None\n\n            if user_res:\n                interaction: Union[str, None] = None\n                if spec.type == \"text\":\n                    message_dict_res = cast(StepDict, user_res)\n                    await self.process_message(\n                        {\"message\": message_dict_res, \"fileReferences\": None}\n                    )\n                    interaction = message_dict_res[\"output\"]\n                    final_res = message_dict_res\n                elif spec.type == \"file\":\n                    file_refs = cast(List[FileReference], user_res)\n                    files = [\n                        self.session.files[file[\"id\"]]\n                        for file in file_refs\n                        if file[\"id\"] in self.session.files\n                    ]\n                    final_res = files\n                    interaction = \",\".join([file[\"name\"] for file in files])\n                    if get_data_layer():\n                        coros = [\n                            File(\n                                id=file[\"id\"],\n                                name=file[\"name\"],\n                                path=str(file[\"path\"]),\n                                mime=file[\"type\"],\n                                chainlit_key=file[\"id\"],\n                                for_id=step_dict[\"id\"],\n                            )._create()\n                            for file in files\n                        ]\n                        await asyncio.gather(*coros)\n                elif spec.type == \"action\":\n                    action_res = cast(AskActionResponse, user_res)\n                    final_res = action_res\n                    interaction = action_res[\"name\"]\n                elif spec.type == \"element\":\n                    final_res = cast(AskElementResponse, user_res)\n                    interaction = \"custom_element\"\n\n                if not self.session.has_first_interaction and interaction:\n                    self.session.has_first_interaction = True\n                    await self.init_thread(interaction=interaction)\n\n            await self.clear(\"clear_ask\")\n            return final_res\n        except TimeoutError as e:\n            await self.send_timeout(\"ask_timeout\")\n\n            if raise_on_timeout:\n                raise e\n        finally:\n            if parent_id in self.session.files_spec:\n                del self.session.files_spec[parent_id]\n            await self.task_start()\n\n    async def send_call_fn(\n        self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False\n    ) -> Optional[Dict[str, Any]]:\n        \"\"\"Stub method to send a call function event to the copilot and wait for a response.\"\"\"\n        try:\n            call_fn_res = await self.emit_call(\n                \"call_fn\", {\"name\": name, \"args\": args}, timeout\n            )  # type: Dict\n\n            await self.clear(\"clear_call_fn\")\n            return call_fn_res\n        except TimeoutError as e:\n            await self.send_timeout(\"call_fn_timeout\")\n\n            if raise_on_timeout:\n                raise e\n            return None\n\n    def update_token_count(self, count: int):\n        \"\"\"Update the token count for the UI.\"\"\"\n\n        return self.emit(\"token_usage\", count)\n\n    def task_start(self):\n        \"\"\"\n        Send a task start signal to the UI.\n        \"\"\"\n        return self.emit(\"task_start\", {})\n\n    def task_end(self):\n        \"\"\"Send a task end signal to the UI.\"\"\"\n        return self.emit(\"task_end\", {})\n\n    def stream_start(self, step_dict: StepDict):\n        \"\"\"Send a stream start signal to the UI.\"\"\"\n        return self.emit(\n            \"stream_start\",\n            step_dict,\n        )\n\n    def send_token(self, id: str, token: str, is_sequence=False, is_input=False):\n        \"\"\"Send a message token to the UI.\"\"\"\n        return self.emit(\n            \"stream_token\",\n            {\"id\": id, \"token\": token, \"isSequence\": is_sequence, \"isInput\": is_input},\n        )\n\n    def set_chat_settings(self, settings: Dict[str, Any]):\n        self.session.chat_settings = settings\n\n    def set_commands(self, commands: List[CommandDict]):\n        \"\"\"Send the available commands to the UI.\"\"\"\n        return self.emit(\n            \"set_commands\",\n            commands,\n        )\n\n    def set_modes(self, modes: List[Mode]):\n        \"\"\"Send the available modes to the UI.\"\"\"\n        return self.emit(\n            \"set_modes\",\n            [mode.to_dict() for mode in modes],\n        )\n\n    def set_favorites(self, steps: List[StepDict]):\n        \"\"\"Send the favorite messages to the UI.\"\"\"\n        return self.emit(\n            \"set_favorites\",\n            steps,\n        )\n\n    def send_window_message(self, data: Any):\n        \"\"\"Send custom data to the host window.\"\"\"\n        return self.emit(\"window_message\", data)\n\n    def send_toast(self, message: str, type: Optional[ToastType] = \"info\"):\n        \"\"\"Send a toast message to the UI.\"\"\"\n        # check that the type is valid using ToastType\n        if type not in get_args(ToastType):\n            raise ValueError(f\"Invalid toast type: {type}\")\n        return self.emit(\"toast\", {\"message\": message, \"type\": type})\n"
  },
  {
    "path": "backend/chainlit/input_widget.py",
    "content": "from abc import abstractmethod\nfrom datetime import date\nfrom typing import Any, Dict, List, Literal, Optional\n\nfrom pydantic import Field\nfrom pydantic.dataclasses import dataclass\n\nfrom chainlit.types import InputWidgetType\n\n\n@dataclass\nclass InputWidget:\n    id: str\n    label: str\n    initial: Any = None\n    tooltip: Optional[str] = None\n    description: Optional[str] = None\n    disabled: Optional[bool] = False\n\n    def __post_init__(\n        self,\n    ) -> None:\n        if not self.id or not self.label:\n            raise ValueError(\"Must provide key and label to load InputWidget\")\n\n    @abstractmethod\n    def to_dict(self) -> Dict[str, Any]:\n        pass\n\n\n@dataclass\nclass Switch(InputWidget):\n    \"\"\"Useful to create a switch input.\"\"\"\n\n    type: InputWidgetType = \"switch\"\n    initial: bool = False\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass Slider(InputWidget):\n    \"\"\"Useful to create a slider input.\"\"\"\n\n    type: InputWidgetType = \"slider\"\n    initial: float = 0\n    min: float = 0\n    max: float = 10\n    step: float = 1\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"min\": self.min,\n            \"max\": self.max,\n            \"step\": self.step,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass Select(InputWidget):\n    \"\"\"Useful to create a select input.\"\"\"\n\n    type: InputWidgetType = \"select\"\n    initial: Optional[str] = None\n    initial_index: Optional[int] = None\n    initial_value: Optional[str] = None\n    values: List[str] = Field(default_factory=list)\n    items: Dict[str, str] = Field(default_factory=dict)\n\n    def __post_init__(\n        self,\n    ) -> None:\n        super().__post_init__()\n\n        if not self.values and not self.items:\n            raise ValueError(\"Must provide values or items to create a Select\")\n\n        if self.values and self.items:\n            raise ValueError(\n                \"You can only provide either values or items to create a Select\"\n            )\n\n        if not self.values and self.initial_index is not None:\n            raise ValueError(\n                \"Initial_index can only be used in combination with values to create a Select\"\n            )\n\n        if self.items:\n            self.initial = self.initial_value\n        elif self.values:\n            self.items = {value: value for value in self.values}\n            self.initial = (\n                self.values[self.initial_index]\n                if self.initial_index is not None\n                else self.initial_value\n            )\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"items\": [\n                {\"label\": id, \"value\": value} for id, value in self.items.items()\n            ],\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass TextInput(InputWidget):\n    \"\"\"Useful to create a text input.\"\"\"\n\n    type: InputWidgetType = \"textinput\"\n    initial: Optional[str] = None\n    placeholder: Optional[str] = None\n    multiline: bool = False\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"placeholder\": self.placeholder,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"multiline\": self.multiline,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass NumberInput(InputWidget):\n    \"\"\"Useful to create a number input.\"\"\"\n\n    type: InputWidgetType = \"numberinput\"\n    initial: Optional[float] = None\n    placeholder: Optional[str] = None\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"placeholder\": self.placeholder,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass Tags(InputWidget):\n    \"\"\"Useful to create an input for an array of strings.\"\"\"\n\n    type: InputWidgetType = \"tags\"\n    initial: List[str] = Field(default_factory=list)\n    values: List[str] = Field(default_factory=list)\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass MultiSelect(InputWidget):\n    \"\"\"Useful to create a multi-select input.\"\"\"\n\n    type: InputWidgetType = \"multiselect\"\n    initial: List[str] = Field(default_factory=list)\n    values: List[str] = Field(default_factory=list)\n    items: Dict[str, str] = Field(default_factory=dict)\n\n    def __post_init__(\n        self,\n    ) -> None:\n        super().__post_init__()\n\n        if not self.values and not self.items:\n            raise ValueError(\"Must provide values or items to create a MultiSelect\")\n\n        if self.values and self.items:\n            raise ValueError(\n                \"You can only provide either values or items to create a MultiSelect\"\n            )\n\n        if self.values:\n            self.items = {value: value for value in self.values}\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"items\": [\n                {\"label\": id, \"value\": value} for id, value in self.items.items()\n            ],\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass Checkbox(InputWidget):\n    \"\"\"Useful to create a checkbox input.\"\"\"\n\n    type: InputWidgetType = \"checkbox\"\n    initial: bool = False\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass RadioGroup(InputWidget):\n    \"\"\"Useful to create a radio button input.\"\"\"\n\n    type: InputWidgetType = \"radio\"\n    initial: Optional[str] = None\n    initial_index: Optional[int] = None\n    initial_value: Optional[str] = None\n    values: List[str] = Field(default_factory=list)\n    items: Dict[str, str] = Field(default_factory=dict)\n\n    def __post_init__(\n        self,\n    ) -> None:\n        super().__post_init__()\n\n        if not self.values and not self.items:\n            raise ValueError(\"Must provide values or items to create a RadioButton\")\n\n        if self.values and self.items:\n            raise ValueError(\n                \"You can only provide either values or items to create a RadioButton\"\n            )\n\n        if not self.values and self.initial_index is not None:\n            raise ValueError(\n                \"Initial_index can only be used in combination with values to create a RadioButton\"\n            )\n\n        if self.items:\n            self.initial = self.initial_value\n        elif self.values:\n            self.items = {value: value for value in self.values}\n            self.initial = (\n                self.values[self.initial_index]\n                if self.initial_index is not None\n                else self.initial_value\n            )\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"initial\": self.initial,\n            \"items\": [\n                {\"label\": id, \"value\": value} for id, value in self.items.items()\n            ],\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"disabled\": self.disabled,\n        }\n\n\n@dataclass\nclass Tab:\n    id: str\n    label: str\n    inputs: list[InputWidget] = Field(default_factory=list, exclude=True)\n\n    def to_dict(self) -> dict[str, Any]:\n        return {\n            \"id\": self.id,\n            \"label\": self.label,\n            \"inputs\": [input.to_dict() for input in self.inputs],\n        }\n\n\n@dataclass\nclass DatePicker(InputWidget):\n    \"\"\"\n    Datepicker input widget.\n    Supports both single date and date range selection.\n    \"\"\"\n\n    type: InputWidgetType = \"datepicker\"\n    mode: Literal[\"single\", \"range\"] = \"single\"\n    initial: str | date | tuple[str | date, str | date] | None = None\n    min_date: str | date | None = None\n    max_date: str | date | None = None\n    format: str | None = None\n    \"\"\"date-fns format string\"\"\"\n    placeholder: str | None = None\n    \"\"\"Placeholder to use when no date is selected\"\"\"\n\n    def __post_init__(self) -> None:\n        super().__post_init__()\n\n        if self.mode not in (\"single\", \"range\"):\n            raise ValueError(\"mode must be 'single' or 'range'\")\n\n        if (\n            self.mode == \"range\"\n            and self.initial is not None\n            and not isinstance(self.initial, tuple)\n        ):\n            raise ValueError(\"'initial' must be a tuple for range mode\")\n\n        (initial_start, initial_end), min_date, max_date = (\n            [\n                DatePicker._validate_iso_format(date, \"initial\")\n                for date in (\n                    self.initial\n                    if isinstance(self.initial, tuple)\n                    else [self.initial, None]\n                )\n            ],\n            DatePicker._validate_iso_format(self.min_date, \"min_date\"),\n            DatePicker._validate_iso_format(self.max_date, \"max_date\"),\n        )\n\n        if self.mode == \"range\":\n            self._validate_range(initial_start, initial_end, \"initial\")\n            self._validate_range(min_date, max_date, \"[min_date, max_date]\")\n\n        # Validate that initial value(s) are within min_date and max_date bounds\n        for d in [initial_start, initial_end]:\n            if d is not None and (\n                (min_date is not None and d < min_date)\n                or (max_date is not None and d > max_date)\n            ):\n                raise ValueError(\n                    \"'initial' must be within 'min_date' and 'max_date' bounds\"\n                )\n\n    @staticmethod\n    def _validate_range(\n        start: date | None,\n        end: date | None,\n        field_name: str,\n    ) -> None:\n        if start is not None and end is not None and start > end:\n            raise ValueError(\n                f\"'{field_name}' range is invalid, start must be before end.\"\n            )\n\n    @staticmethod\n    def _validate_iso_format(\n        date_value: str | date | None, field_name: str\n    ) -> date | None:\n        if isinstance(date_value, str):\n            try:\n                return date.fromisoformat(date_value)\n            except ValueError as e:\n                raise ValueError(f\"'{field_name}' must be in ISO format\") from e\n\n        return date_value\n\n    @staticmethod\n    def _format_date(date_value: str | date | None) -> str | None:\n        if isinstance(date_value, date):\n            return date_value.isoformat()\n        return date_value\n\n    def to_dict(self) -> dict[str, Any]:\n        return {\n            \"type\": self.type,\n            \"id\": self.id,\n            \"label\": self.label,\n            \"tooltip\": self.tooltip,\n            \"description\": self.description,\n            \"mode\": self.mode,\n            \"initial\": (\n                self._format_date(self.initial[0]),\n                self._format_date(self.initial[1]),\n            )\n            if isinstance(self.initial, tuple)\n            else DatePicker._format_date(self.initial),\n            \"min_date\": DatePicker._format_date(self.min_date),\n            \"max_date\": DatePicker._format_date(self.max_date),\n            \"format\": self.format,\n            \"placeholder\": self.placeholder,\n        }\n"
  },
  {
    "path": "backend/chainlit/langchain/__init__.py",
    "content": "from chainlit.utils import check_module_version\n\nif not check_module_version(\"langchain\", \"0.0.198\"):\n    raise ValueError(\n        \"Expected LangChain version >= 0.0.198. Run `pip install langchain --upgrade`\"\n    )\n"
  },
  {
    "path": "backend/chainlit/langchain/callbacks.py",
    "content": "import time\nfrom typing import Any, Dict, List, Optional, Tuple, TypedDict, Union\nfrom uuid import UUID\n\nimport pydantic\nfrom langchain_core.load import dumps\nfrom langchain_core.messages import BaseMessage\nfrom langchain_core.outputs import ChatGenerationChunk, GenerationChunk\nfrom langchain_core.tracers.base import AsyncBaseTracer\nfrom langchain_core.tracers.schemas import Run\nfrom literalai import ChatGeneration, CompletionGeneration, GenerationMessage\nfrom literalai.observability.step import TrueStepType\n\nfrom chainlit.context import context_var\nfrom chainlit.message import Message\nfrom chainlit.step import Step\nfrom chainlit.utils import utc_now\n\nDEFAULT_ANSWER_PREFIX_TOKENS = [\"Final\", \"Answer\", \":\"]\n\n\nclass FinalStreamHelper:\n    # The stream we can use to stream the final answer from a chain\n    final_stream: Union[Message, None]\n    # Should we stream the final answer?\n    stream_final_answer: bool = False\n    # Token sequence that prefixes the answer\n    answer_prefix_tokens: List[str]\n    # Ignore white spaces and new lines when comparing answer_prefix_tokens to last tokens? (to determine if answer has been reached)\n    strip_tokens: bool\n\n    answer_reached: bool\n\n    def __init__(\n        self,\n        answer_prefix_tokens: Optional[List[str]] = None,\n        stream_final_answer: bool = False,\n        force_stream_final_answer: bool = False,\n        strip_tokens: bool = True,\n    ) -> None:\n        # Langchain final answer streaming logic\n        if answer_prefix_tokens is None:\n            self.answer_prefix_tokens = DEFAULT_ANSWER_PREFIX_TOKENS\n        else:\n            self.answer_prefix_tokens = answer_prefix_tokens\n        if strip_tokens:\n            self.answer_prefix_tokens_stripped = [\n                token.strip() for token in self.answer_prefix_tokens\n            ]\n        else:\n            self.answer_prefix_tokens_stripped = self.answer_prefix_tokens\n\n        self.last_tokens = [\"\"] * len(self.answer_prefix_tokens)\n        self.last_tokens_stripped = [\"\"] * len(self.answer_prefix_tokens)\n        self.strip_tokens = strip_tokens\n        self.answer_reached = force_stream_final_answer\n\n        # Our own final answer streaming logic\n        self.stream_final_answer = stream_final_answer\n        self.final_stream = None\n        self.has_streamed_final_answer = False\n\n    def _check_if_answer_reached(self) -> bool:\n        if self.strip_tokens:\n            return self._compare_last_tokens(self.last_tokens_stripped)\n        else:\n            return self._compare_last_tokens(self.last_tokens)\n\n    def _compare_last_tokens(self, last_tokens: List[str]):\n        if last_tokens == self.answer_prefix_tokens_stripped:\n            # If tokens match perfectly we are done\n            return True\n        else:\n            # Some LLMs will consider all the tokens of the final answer as one token\n            # so we check if any last token contains all answer tokens\n            return any(\n                [\n                    all(\n                        answer_token in last_token\n                        for answer_token in self.answer_prefix_tokens_stripped\n                    )\n                    for last_token in last_tokens\n                ]\n            )\n\n    def _append_to_last_tokens(self, token: str) -> None:\n        self.last_tokens.append(token)\n        self.last_tokens_stripped.append(token.strip())\n        if len(self.last_tokens) > len(self.answer_prefix_tokens):\n            self.last_tokens.pop(0)\n            self.last_tokens_stripped.pop(0)\n\n\nclass ChatGenerationStart(TypedDict):\n    input_messages: List[BaseMessage]\n    start: float\n    token_count: int\n    tt_first_token: Optional[float]\n\n\nclass CompletionGenerationStart(TypedDict):\n    prompt: str\n    start: float\n    token_count: int\n    tt_first_token: Optional[float]\n\n\nclass GenerationHelper:\n    chat_generations: Dict[str, ChatGenerationStart]\n    completion_generations: Dict[str, CompletionGenerationStart]\n    generation_inputs: Dict[str, Dict]\n\n    def __init__(self) -> None:\n        self.chat_generations = {}\n        self.completion_generations = {}\n        self.generation_inputs = {}\n\n    def ensure_values_serializable(self, data):\n        \"\"\"\n        Recursively ensures that all values in the input (dict or list) are JSON serializable.\n        \"\"\"\n        if isinstance(data, dict):\n            return {\n                key: self.ensure_values_serializable(value)\n                for key, value in data.items()\n            }\n        elif isinstance(data, pydantic.BaseModel):\n            # Fallback to support pydantic v1\n            # https://docs.pydantic.dev/latest/migration/#changes-to-pydanticbasemodel\n            if pydantic.VERSION.startswith(\"1\"):\n                return data.dict()\n\n            # pydantic v2\n            return data.model_dump()  # pyright: ignore reportAttributeAccessIssue\n        elif isinstance(data, list):\n            return [self.ensure_values_serializable(item) for item in data]\n        elif isinstance(data, (str, int, float, bool, type(None))):\n            return data\n        elif isinstance(data, (tuple, set)):\n            return list(data)  # Convert tuples and sets to lists\n        else:\n            return str(data)  # Fallback: convert other types to string\n\n    def _convert_message_role(self, role: str):\n        if \"human\" in role.lower():\n            return \"user\"\n        elif \"system\" in role.lower():\n            return \"system\"\n        elif \"function\" in role.lower():\n            return \"function\"\n        elif \"tool\" in role.lower():\n            return \"tool\"\n        else:\n            return \"assistant\"\n\n    def _convert_message_dict(\n        self,\n        message: Dict,\n    ):\n        class_name = message[\"id\"][-1]\n        kwargs = message.get(\"kwargs\", {})\n        function_call = kwargs.get(\"additional_kwargs\", {}).get(\"function_call\")\n\n        msg = GenerationMessage(\n            role=self._convert_message_role(class_name),\n            content=\"\",\n        )\n        if name := kwargs.get(\"name\"):\n            msg[\"name\"] = name\n        if function_call:\n            msg[\"function_call\"] = function_call\n        else:\n            content = kwargs.get(\"content\")\n            if isinstance(content, list):\n                tool_calls = []\n                content_parts = []\n                for item in content:\n                    if item.get(\"type\") == \"tool_use\":\n                        tool_calls.append(\n                            {\n                                \"id\": item.get(\"id\"),\n                                \"type\": \"function\",\n                                \"function\": {\n                                    \"name\": item.get(\"name\"),\n                                    \"arguments\": item.get(\"input\"),\n                                },\n                            }\n                        )\n                    elif item.get(\"type\") == \"text\":\n                        content_parts.append({\"type\": \"text\", \"text\": item.get(\"text\")})\n\n                if tool_calls:\n                    msg[\"tool_calls\"] = tool_calls\n                if content_parts:\n                    msg[\"content\"] = content_parts  # type: ignore\n            else:\n                msg[\"content\"] = content  # type: ignore\n\n        return msg\n\n    def _convert_message(\n        self,\n        message: Union[Dict, BaseMessage],\n    ):\n        if isinstance(message, dict):\n            return self._convert_message_dict(\n                message,\n            )\n\n        function_call = message.additional_kwargs.get(\"function_call\")\n\n        msg = GenerationMessage(\n            role=self._convert_message_role(message.type),\n            content=\"\",\n        )\n\n        if literal_uuid := message.additional_kwargs.get(\"uuid\"):\n            msg[\"uuid\"] = literal_uuid\n            msg[\"templated\"] = True\n\n        if name := getattr(message, \"name\", None):\n            msg[\"name\"] = name\n\n        if function_call:\n            msg[\"function_call\"] = function_call\n        else:\n            if isinstance(message.content, list):\n                tool_calls = []\n                content_parts = []\n                for item in message.content:\n                    if isinstance(item, str):\n                        continue\n                    if item.get(\"type\") == \"tool_use\":\n                        tool_calls.append(\n                            {\n                                \"id\": item.get(\"id\"),\n                                \"type\": \"function\",\n                                \"function\": {\n                                    \"name\": item.get(\"name\"),\n                                    \"arguments\": item.get(\"input\"),\n                                },\n                            }\n                        )\n                    elif item.get(\"type\") == \"text\":\n                        content_parts.append({\"type\": \"text\", \"text\": item.get(\"text\")})\n\n                if tool_calls:\n                    msg[\"tool_calls\"] = tool_calls\n                if content_parts:\n                    msg[\"content\"] = content_parts  # type: ignore\n            else:\n                msg[\"content\"] = message.content  # type: ignore\n\n        return msg\n\n    def _build_llm_settings(\n        self,\n        serialized: Dict,\n        invocation_params: Optional[Dict] = None,\n    ):\n        # invocation_params = run.extra.get(\"invocation_params\")\n        if invocation_params is None:\n            return None, None\n\n        provider = invocation_params.pop(\"_type\", \"\")  # type: str\n\n        model_kwargs = invocation_params.pop(\"model_kwargs\", {})\n\n        if model_kwargs is None:\n            model_kwargs = {}\n\n        merged = {\n            **invocation_params,\n            **model_kwargs,\n            **serialized.get(\"kwargs\", {}),\n        }\n\n        # make sure there is no api key specification\n        settings = {k: v for k, v in merged.items() if not k.endswith(\"_api_key\")}\n\n        model_keys = [\"azure_deployment\", \"deployment_name\", \"model\", \"model_name\"]\n        model = next((settings[k] for k in model_keys if k in settings), None)\n        if isinstance(model, str):\n            model = model.replace(\"models/\", \"\")\n        tools = None\n        if \"functions\" in settings:\n            tools = [{\"type\": \"function\", \"function\": f} for f in settings[\"functions\"]]\n        if \"tools\" in settings:\n            tools = [\n                {\"type\": \"function\", \"function\": t}\n                if t.get(\"type\") != \"function\"\n                else t\n                for t in settings[\"tools\"]\n            ]\n        return provider, model, tools, settings\n\n\ndef process_content(content: Any) -> Tuple[Dict | str, Optional[str]]:\n    if content is None:\n        return {}, None\n    if isinstance(content, str):\n        return {\"content\": content}, \"text\"\n    else:\n        return dumps(content), \"json\"\n\n\nDEFAULT_TO_IGNORE = [\n    \"RunnableSequence\",\n    \"RunnableParallel\",\n    \"RunnableAssign\",\n    \"RunnableLambda\",\n    \"<lambda>\",\n]\nDEFAULT_TO_KEEP = [\"retriever\", \"llm\", \"agent\", \"chain\", \"tool\"]\n\n\nclass LangchainTracer(AsyncBaseTracer, GenerationHelper, FinalStreamHelper):\n    steps: Dict[str, Step]\n    parent_id_map: Dict[str, str]\n    ignored_runs: set\n\n    def __init__(\n        self,\n        # Token sequence that prefixes the answer\n        answer_prefix_tokens: Optional[List[str]] = None,\n        # Should we stream the final answer?\n        stream_final_answer: bool = False,\n        # Should force stream the first response?\n        force_stream_final_answer: bool = False,\n        # Runs to ignore to enhance readability\n        to_ignore: Optional[List[str]] = None,\n        # Runs to keep within ignored runs\n        to_keep: Optional[List[str]] = None,\n        **kwargs: Any,\n    ) -> None:\n        AsyncBaseTracer.__init__(self, **kwargs)\n        GenerationHelper.__init__(self)\n        FinalStreamHelper.__init__(\n            self,\n            answer_prefix_tokens=answer_prefix_tokens,\n            stream_final_answer=stream_final_answer,\n            force_stream_final_answer=force_stream_final_answer,\n        )\n        self.context = context_var.get()\n        self.steps = {}\n        self.parent_id_map = {}\n        self.ignored_runs = set()\n\n        if self.context.current_step:\n            self.root_parent_id = self.context.current_step.id\n        else:\n            self.root_parent_id = None\n\n        if to_ignore is None:\n            self.to_ignore = DEFAULT_TO_IGNORE\n        else:\n            self.to_ignore = to_ignore\n\n        if to_keep is None:\n            self.to_keep = DEFAULT_TO_KEEP\n        else:\n            self.to_keep = to_keep\n\n    async def on_chat_model_start(\n        self,\n        serialized: Dict[str, Any],\n        messages: List[List[BaseMessage]],\n        *,\n        run_id: \"UUID\",\n        parent_run_id: Optional[\"UUID\"] = None,\n        tags: Optional[List[str]] = None,\n        metadata: Optional[Dict[str, Any]] = None,\n        name: Optional[str] = None,\n        **kwargs: Any,\n    ) -> Run:\n        lc_messages = messages[0]\n        self.chat_generations[str(run_id)] = {\n            \"input_messages\": lc_messages,\n            \"start\": time.time(),\n            \"token_count\": 0,\n            \"tt_first_token\": None,\n        }\n\n        return await super().on_chat_model_start(\n            serialized,\n            messages,\n            run_id=run_id,\n            parent_run_id=parent_run_id,\n            tags=tags,\n            metadata=metadata,\n            name=name,\n            **kwargs,\n        )\n\n    async def on_llm_start(\n        self,\n        serialized: Dict[str, Any],\n        prompts: List[str],\n        *,\n        run_id: \"UUID\",\n        parent_run_id: Optional[UUID] = None,\n        tags: Optional[List[str]] = None,\n        metadata: Optional[Dict[str, Any]] = None,\n        **kwargs: Any,\n    ) -> None:\n        await super().on_llm_start(\n            serialized,\n            prompts,\n            run_id=run_id,\n            parent_run_id=parent_run_id,\n            tags=tags,\n            metadata=metadata,\n            **kwargs,\n        )\n\n        self.completion_generations[str(run_id)] = {\n            \"prompt\": prompts[0],\n            \"start\": time.time(),\n            \"token_count\": 0,\n            \"tt_first_token\": None,\n        }\n\n        return None\n\n    async def on_llm_new_token(\n        self,\n        token: str,\n        *,\n        chunk: Optional[Union[GenerationChunk, ChatGenerationChunk]] = None,\n        run_id: \"UUID\",\n        parent_run_id: Optional[\"UUID\"] = None,\n        **kwargs: Any,\n    ) -> None:\n        await super().on_llm_new_token(\n            token=token,\n            chunk=chunk,\n            run_id=run_id,\n            parent_run_id=parent_run_id,\n            **kwargs,\n        )\n        if isinstance(chunk, ChatGenerationChunk):\n            start = self.chat_generations[str(run_id)]\n        else:\n            start = self.completion_generations[str(run_id)]  # type: ignore\n        start[\"token_count\"] += 1\n        if start[\"tt_first_token\"] is None:\n            start[\"tt_first_token\"] = (time.time() - start[\"start\"]) * 1000\n\n        # Process token to ensure it's a string, as strip() will be called on it.\n        processed_token: str\n        # Handle case where token is a list (can occur with some model outputs).\n        # Join all elements into a single string to maintain compatibility with downstream processing.\n        if isinstance(token, list):\n            # If token is a list, join its elements (converted to strings) into a single string.\n            processed_token = \"\".join(map(str, token))\n        elif not isinstance(token, str):\n            # If token is neither a list nor a string, convert it to a string.\n            processed_token = str(token)\n        else:\n            # If token is already a string, use it as is.\n            processed_token = token\n\n        if self.stream_final_answer:\n            self._append_to_last_tokens(processed_token)\n\n            if self.answer_reached:\n                if not self.final_stream:\n                    self.final_stream = Message(content=\"\")\n                    await self.final_stream.send()\n                await self.final_stream.stream_token(processed_token)\n                self.has_streamed_final_answer = True\n            else:\n                self.answer_reached = self._check_if_answer_reached()\n\n    async def _persist_run(self, run: Run) -> None:\n        pass\n\n    def _get_run_parent_id(self, run: Run):\n        parent_id = str(run.parent_run_id) if run.parent_run_id else self.root_parent_id\n\n        return parent_id\n\n    def _get_non_ignored_parent_id(self, current_parent_id: Optional[str] = None):\n        if not current_parent_id:\n            return self.root_parent_id\n\n        if current_parent_id not in self.parent_id_map:\n            return None\n\n        while current_parent_id in self.parent_id_map:\n            # If the parent id is in the ignored runs, we need to get the parent id of the ignored run\n            if current_parent_id in self.ignored_runs:\n                current_parent_id = self.parent_id_map[current_parent_id]\n            else:\n                return current_parent_id\n\n        return self.root_parent_id\n\n    def _should_ignore_run(self, run: Run):\n        parent_id = self._get_run_parent_id(run)\n\n        if parent_id:\n            # Add the parent id of the ignored run in the mapping\n            # so we can re-attach a kept child to the right parent id\n            self.parent_id_map[str(run.id)] = parent_id\n\n        ignore_by_name = False\n        ignore_by_parent = parent_id in self.ignored_runs\n\n        for filter in self.to_ignore:\n            if filter in run.name:\n                ignore_by_name = True\n                break\n\n        ignore = ignore_by_name or ignore_by_parent\n\n        # If the ignore cause is the parent being ignored, check if we should nonetheless keep the child\n        if ignore_by_parent and not ignore_by_name and run.run_type in self.to_keep:\n            return False, self._get_non_ignored_parent_id(parent_id)\n        else:\n            if ignore:\n                # Tag the run as ignored\n                self.ignored_runs.add(str(run.id))\n            return ignore, parent_id\n\n    async def _start_trace(self, run: Run) -> None:\n        await super()._start_trace(run)\n        context_var.set(self.context)\n\n        ignore, parent_id = self._should_ignore_run(run)\n\n        if run.run_type in [\"chain\", \"prompt\"]:\n            self.generation_inputs[str(run.id)] = self.ensure_values_serializable(\n                run.inputs\n            )\n\n        if ignore:\n            return\n\n        step_type: TrueStepType = \"undefined\"\n        if run.run_type == \"agent\":\n            step_type = \"run\"\n        elif run.run_type == \"chain\":\n            if not self.steps:\n                step_type = \"run\"\n        elif run.run_type == \"llm\":\n            step_type = \"llm\"\n        elif run.run_type == \"retriever\":\n            step_type = \"tool\"\n        elif run.run_type == \"tool\":\n            step_type = \"tool\"\n        elif run.run_type == \"embedding\":\n            step_type = \"embedding\"\n\n        step = Step(\n            id=str(run.id),\n            name=run.name,\n            type=step_type,\n            parent_id=parent_id,\n        )\n        step.start = utc_now()\n        if step_type != \"llm\":\n            step.input, language = process_content(run.inputs)\n            step.show_input = language or False\n\n        step.tags = run.tags\n        self.steps[str(run.id)] = step\n\n        await step.send()\n\n    async def _on_run_update(self, run: Run) -> None:\n        \"\"\"Process a run upon update.\"\"\"\n        context_var.set(self.context)\n\n        ignore, _parent_id = self._should_ignore_run(run)\n\n        if ignore:\n            return\n\n        current_step = self.steps.get(str(run.id), None)\n\n        if run.run_type == \"llm\" and current_step:\n            provider, model, tools, llm_settings = self._build_llm_settings(\n                (run.serialized or {}), (run.extra or {}).get(\"invocation_params\")\n            )\n            generations = (run.outputs or {}).get(\"generations\", [])\n            generation = generations[0][0]\n            variables = self.generation_inputs.get(str(run.parent_run_id), {})\n            variables = {k: str(v) for k, v in variables.items() if v is not None}\n            if message := generation.get(\"message\"):\n                chat_start = self.chat_generations[str(run.id)]\n                duration = time.time() - chat_start[\"start\"]\n                if duration and chat_start[\"token_count\"]:\n                    throughput = chat_start[\"token_count\"] / duration\n                else:\n                    throughput = None\n                message_completion = self._convert_message(message)\n                current_step.generation = ChatGeneration(\n                    provider=provider,\n                    model=model,\n                    tools=tools,\n                    variables=variables,\n                    settings=llm_settings,\n                    duration=duration,\n                    token_throughput_in_s=throughput,\n                    tt_first_token=chat_start.get(\"tt_first_token\"),\n                    messages=[\n                        self._convert_message(m) for m in chat_start[\"input_messages\"]\n                    ],\n                    message_completion=message_completion,\n                )\n\n                # find first message with prompt_id\n                for m in chat_start[\"input_messages\"]:\n                    if m.additional_kwargs.get(\"prompt_id\"):\n                        current_step.generation.prompt_id = m.additional_kwargs[\n                            \"prompt_id\"\n                        ]\n                        if custom_variables := m.additional_kwargs.get(\"variables\"):\n                            current_step.generation.variables = {\n                                k: str(v)\n                                for k, v in custom_variables.items()\n                                if v is not None\n                            }\n                    break\n\n                current_step.language = \"json\"\n            else:\n                completion_start = self.completion_generations[str(run.id)]\n                completion = generation.get(\"text\", \"\")\n                duration = time.time() - completion_start[\"start\"]\n                if duration and completion_start[\"token_count\"]:\n                    throughput = completion_start[\"token_count\"] / duration\n                else:\n                    throughput = None\n                current_step.generation = CompletionGeneration(\n                    provider=provider,\n                    model=model,\n                    settings=llm_settings,\n                    variables=variables,\n                    duration=duration,\n                    token_throughput_in_s=throughput,\n                    tt_first_token=completion_start.get(\"tt_first_token\"),\n                    prompt=completion_start[\"prompt\"],\n                    completion=completion,\n                )\n                current_step.output = completion\n\n            if current_step:\n                current_step.end = utc_now()\n                await current_step.update()\n\n            if self.final_stream and self.has_streamed_final_answer:\n                await self.final_stream.update()\n\n            return\n\n        if current_step:\n            if current_step.type != \"llm\":\n                current_step.output, current_step.language = process_content(\n                    run.outputs\n                )\n            current_step.end = utc_now()\n            await current_step.update()\n\n    async def _on_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any):\n        context_var.set(self.context)\n\n        if current_step := self.steps.get(str(run_id), None):\n            current_step.is_error = True\n            current_step.output = str(error)\n            current_step.end = utc_now()\n            await current_step.update()\n\n    on_llm_error = _on_error\n    on_chain_error = _on_error\n    on_tool_error = _on_error\n    on_retriever_error = _on_error\n\n\nLangchainCallbackHandler = LangchainTracer\nAsyncLangchainCallbackHandler = LangchainTracer\n"
  },
  {
    "path": "backend/chainlit/langflow/__init__.py",
    "content": "from chainlit.utils import check_module_version\n\nif not check_module_version(\"langflow\", \"0.1.4\"):\n    raise ValueError(\n        \"Expected Langflow version >= 0.1.4. Run `pip install langflow --upgrade`\"\n    )\n\nfrom typing import Dict, Optional, Union\n\nimport httpx\n\n\nasync def load_flow(schema: Union[Dict, str], tweaks: Optional[Dict] = None):\n    from langflow import load_flow_from_json\n\n    if isinstance(schema, str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(schema)\n            if response.status_code != 200:\n                raise ValueError(f\"Error: {response.text}\")\n            schema = response.json()\n\n    flow = load_flow_from_json(flow=schema, tweaks=tweaks)\n\n    return flow\n"
  },
  {
    "path": "backend/chainlit/llama_index/__init__.py",
    "content": "from chainlit.utils import check_module_version\n\nif not check_module_version(\"llama_index.core\", \"0.10.15\"):\n    raise ValueError(\n        \"Expected LlamaIndex version >= 0.10.15. Run `pip install llama_index --upgrade`\"\n    )\n"
  },
  {
    "path": "backend/chainlit/llama_index/callbacks.py",
    "content": "from typing import Any, Dict, List, Optional\n\nfrom literalai import ChatGeneration, CompletionGeneration, GenerationMessage\nfrom llama_index.core.callbacks import TokenCountingHandler\nfrom llama_index.core.callbacks.schema import CBEventType, EventPayload\nfrom llama_index.core.llms import ChatMessage, ChatResponse, CompletionResponse\nfrom llama_index.core.tools.types import ToolMetadata\n\nfrom chainlit.context import context_var\nfrom chainlit.element import Text\nfrom chainlit.step import Step, StepType\nfrom chainlit.utils import utc_now\n\nDEFAULT_IGNORE = [\n    CBEventType.CHUNKING,\n    CBEventType.SYNTHESIZE,\n    CBEventType.EMBEDDING,\n    CBEventType.NODE_PARSING,\n    CBEventType.TREE,\n]\n\n\nclass LlamaIndexCallbackHandler(TokenCountingHandler):\n    \"\"\"Base callback handler that can be used to track event starts and ends.\"\"\"\n\n    steps: Dict[str, Step]\n\n    def __init__(\n        self,\n        event_starts_to_ignore: List[CBEventType] = DEFAULT_IGNORE,\n        event_ends_to_ignore: List[CBEventType] = DEFAULT_IGNORE,\n    ) -> None:\n        \"\"\"Initialize the base callback handler.\"\"\"\n        super().__init__(\n            event_starts_to_ignore=event_starts_to_ignore,\n            event_ends_to_ignore=event_ends_to_ignore,\n        )\n\n        self.steps = {}\n\n    def _get_parent_id(self, event_parent_id: Optional[str] = None) -> Optional[str]:\n        if event_parent_id and event_parent_id in self.steps:\n            return event_parent_id\n        elif context_var.get().current_step:\n            return context_var.get().current_step.id\n        else:\n            return None\n\n    def on_event_start(\n        self,\n        event_type: CBEventType,\n        payload: Optional[Dict[str, Any]] = None,\n        event_id: str = \"\",\n        parent_id: str = \"\",\n        **kwargs: Any,\n    ) -> str:\n        \"\"\"Run when an event starts and return id of event.\"\"\"\n        step_type: StepType = \"undefined\"\n        step_name: str = event_type.value\n        step_input: Optional[Dict[str, Any]] = payload\n        if event_type == CBEventType.FUNCTION_CALL:\n            step_type = \"tool\"\n            if payload:\n                metadata: Optional[ToolMetadata] = payload.get(EventPayload.TOOL)\n                if metadata:\n                    step_name = getattr(metadata, \"name\", step_name)\n                step_input = payload.get(EventPayload.FUNCTION_CALL)\n        elif event_type == CBEventType.RETRIEVE:\n            step_type = \"tool\"\n        elif event_type == CBEventType.QUERY:\n            step_type = \"tool\"\n        elif event_type == CBEventType.LLM:\n            step_type = \"llm\"\n        else:\n            return event_id\n\n        step = Step(\n            name=step_name,\n            type=step_type,\n            parent_id=self._get_parent_id(parent_id),\n            id=event_id,\n        )\n\n        self.steps[event_id] = step\n        step.start = utc_now()\n        step.input = step_input or {}\n        context_var.get().loop.create_task(step.send())\n        return event_id\n\n    def on_event_end(\n        self,\n        event_type: CBEventType,\n        payload: Optional[Dict[str, Any]] = None,\n        event_id: str = \"\",\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Run when an event ends.\"\"\"\n        step = self.steps.get(event_id, None)\n\n        if payload is None or step is None:\n            return\n\n        step.end = utc_now()\n\n        if event_type == CBEventType.FUNCTION_CALL:\n            response = payload.get(EventPayload.FUNCTION_OUTPUT)\n            if response:\n                step.output = f\"{response}\"\n                context_var.get().loop.create_task(step.update())\n\n        elif event_type == CBEventType.QUERY:\n            response = payload.get(EventPayload.RESPONSE)\n            source_nodes = getattr(response, \"source_nodes\", None)\n            if source_nodes:\n                source_refs = \", \".join(\n                    [f\"Source {idx}\" for idx, _ in enumerate(source_nodes)]\n                )\n                step.elements = [\n                    Text(\n                        name=f\"Source {idx}\",\n                        content=source.text or \"Empty node\",\n                        display=\"side\",\n                    )\n                    for idx, source in enumerate(source_nodes)\n                ]\n                step.output = f\"Retrieved the following sources: {source_refs}\"\n                context_var.get().loop.create_task(step.update())\n\n        elif event_type == CBEventType.RETRIEVE:\n            sources = payload.get(EventPayload.NODES)\n            if sources:\n                source_refs = \", \".join(\n                    [f\"Source {idx}\" for idx, _ in enumerate(sources)]\n                )\n                step.elements = [\n                    Text(\n                        name=f\"Source {idx}\",\n                        display=\"side\",\n                        content=source.node.get_text() or \"Empty node\",\n                    )\n                    for idx, source in enumerate(sources)\n                ]\n                step.output = f\"Retrieved the following sources: {source_refs}\"\n            context_var.get().loop.create_task(step.update())\n\n        elif event_type == CBEventType.LLM:\n            formatted_messages = payload.get(EventPayload.MESSAGES)  # type: Optional[List[ChatMessage]]\n            formatted_prompt = payload.get(EventPayload.PROMPT)\n            response = payload.get(EventPayload.RESPONSE)\n\n            if formatted_messages:\n                messages = [\n                    GenerationMessage(\n                        role=m.role.value,  # type: ignore\n                        content=m.content or \"\",\n                    )\n                    for m in formatted_messages\n                ]\n            else:\n                messages = None\n\n            if isinstance(response, ChatResponse):\n                content = response.message.content or \"\"\n            elif isinstance(response, CompletionResponse):\n                content = response.text\n            else:\n                content = \"\"\n\n            step.output = content\n\n            token_count = self.total_llm_token_count or None\n            raw_response = response.raw if response else None\n            model = getattr(raw_response, \"model\", None)\n\n            if messages and isinstance(response, ChatResponse):\n                msg: ChatMessage = response.message\n                step.generation = ChatGeneration(\n                    model=model,\n                    messages=messages,\n                    message_completion=GenerationMessage(\n                        role=msg.role.value,  # type: ignore\n                        content=content,\n                    ),\n                    token_count=token_count,\n                )\n            elif formatted_prompt:\n                step.generation = CompletionGeneration(\n                    model=model,\n                    prompt=formatted_prompt,\n                    completion=content,\n                    token_count=token_count,\n                )\n\n            context_var.get().loop.create_task(step.update())\n\n        else:\n            step.output = payload\n            context_var.get().loop.create_task(step.update())\n\n        self.steps.pop(event_id, None)\n\n    def _noop(self, *args, **kwargs):\n        pass\n\n    start_trace = _noop\n    end_trace = _noop\n"
  },
  {
    "path": "backend/chainlit/logger.py",
    "content": "import logging\n\nlogging.getLogger(\"socketio\").setLevel(logging.ERROR)\nlogging.getLogger(\"engineio\").setLevel(logging.ERROR)\nlogging.getLogger(\"numexpr\").setLevel(logging.ERROR)\n\n\nlogger = logging.getLogger(\"chainlit\")\n"
  },
  {
    "path": "backend/chainlit/markdown.py",
    "content": "import os\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom chainlit.logger import logger\n\nfrom ._utils import is_path_inside\n\n# Default chainlit.md file created if none exists\nDEFAULT_MARKDOWN_STR = \"\"\"# Welcome to Chainlit! 🚀🤖\n\nHi 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.\n\n## Useful Links 🔗\n\n- **Documentation:** Get started with our comprehensive [Chainlit Documentation](https://docs.chainlit.io) 📚\n- **Discord Community:** Join our friendly [Chainlit Discord](https://discord.gg/k73SQ3FyUh) to ask questions, share your projects, and connect with other developers! 💬\n\nWe can't wait to see what you create with Chainlit! Happy coding! 💻😊\n\n## Welcome screen\n\nTo 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.\n\"\"\"\n\n\ndef init_markdown(root: str):\n    \"\"\"Initialize the chainlit.md file if it doesn't exist.\"\"\"\n    chainlit_md_file = os.path.join(root, \"chainlit.md\")\n\n    if not os.path.exists(chainlit_md_file):\n        with open(chainlit_md_file, \"w\", encoding=\"utf-8\") as f:\n            f.write(DEFAULT_MARKDOWN_STR)\n            logger.info(f\"Created default chainlit markdown file at {chainlit_md_file}\")\n\n\ndef get_markdown_str(root: str, language: str) -> Optional[str]:\n    \"\"\"Get the chainlit.md file as a string.\"\"\"\n    root_path = Path(root)\n    translated_chainlit_md_path = root_path / f\"chainlit_{language}.md\"\n    default_chainlit_md_path = root_path / \"chainlit.md\"\n\n    if (\n        is_path_inside(translated_chainlit_md_path, root_path)\n        and translated_chainlit_md_path.is_file()\n    ):\n        chainlit_md_path = translated_chainlit_md_path\n    else:\n        chainlit_md_path = default_chainlit_md_path\n        logger.warning(\n            f\"Translated markdown file for {language} not found. Defaulting to chainlit.md.\"\n        )\n\n    if chainlit_md_path.is_file():\n        return chainlit_md_path.read_text(encoding=\"utf-8\")\n    else:\n        return None\n"
  },
  {
    "path": "backend/chainlit/mcp.py",
    "content": "import shlex\nfrom typing import Dict, Literal, Optional, Union\n\nfrom pydantic import BaseModel\n\nfrom chainlit.config import config\n\n\nclass StdioMcpConnection(BaseModel):\n    name: str\n    command: str\n    args: list[str]\n    clientType: Literal[\"stdio\"] = \"stdio\"\n\n\nclass SseMcpConnection(BaseModel):\n    name: str\n    url: str\n    headers: Optional[Dict[str, str]] = None\n    clientType: Literal[\"sse\"] = \"sse\"\n\n\nclass HttpMcpConnection(BaseModel):\n    name: str\n    url: str\n    headers: Optional[Dict[str, str]] = None\n    clientType: Literal[\"streamable-http\"] = \"streamable-http\"\n\n\nMcpConnection = Union[StdioMcpConnection, SseMcpConnection, HttpMcpConnection]\n\n\ndef validate_mcp_command(command_string: str):\n    \"\"\"\n    Validates that a command string uses command in the allowed list as the executable and returns\n    the executable and list of arguments suitable for subprocess calls.\n\n    This function handles potential command prefixes, flags, and options\n    to ensure only commands in allowed list are allowed.\n\n    Args:\n        command_string (str): The full command string to validate\n\n    Returns:\n        tuple: (env, executable, args_list) where:\n            - env (dict): Environment variables as a dictionary\n            - executable (str): The executable name or path\n            - args_list (list): List of command arguments\n\n    Raises:\n        ValueError: If the command doesn't use an allowed executable\n    \"\"\"\n    # Split the command string into parts while respecting quotes and escapes\n    # Using shlex.split provides POSIX-compatible parsing so that arguments\n    # wrapped in quotes (e.g. \"--header \\\"Authorization: Bearer TOKEN\\\"\")\n    # or environment variable assignments such as\n    # MY_VAR=\"value with spaces\" are preserved as single list items.\n    # On Windows, shlex also works as long as posix=False is not required for\n    # our use-case (Chainlit targets POSIX-style shells for the MCP command).\n    try:\n        parts = shlex.split(command_string, posix=True)\n    except ValueError as exc:\n        # Provide a clearer error message when the command cannot be parsed\n        raise ValueError(f\"Invalid command string: {exc}\") from exc\n\n    if not parts:\n        raise ValueError(\"Empty command string\")\n\n    # Look for the actual executable in the command\n    executable = None\n    executable_index = None\n    allowed_executables = config.features.mcp.stdio.allowed_executables\n    for i, part in enumerate(parts):\n        # Remove any path components to get the base executable name\n        base_exec = part.split(\"/\")[-1].split(\"\\\\\")[-1]\n        if allowed_executables is None or base_exec in allowed_executables:\n            executable = part\n            executable_index = i\n            break\n\n    if executable is None or executable_index is None:\n        raise ValueError(\n            f\"Only commands in ({', '.join(allowed_executables)}) are allowed\"\n            if allowed_executables\n            else \"No allowed executables found\"\n        )\n\n    # Return `executable` as the executable and everything after it as args\n    args_list = parts[executable_index + 1 :]\n    env_list = parts[:executable_index]\n    env = {}\n    for env_var in env_list:\n        if \"=\" in env_var:\n            key, value = env_var.split(\"=\", 1)\n            env[key] = value\n        else:\n            raise ValueError(f\"Invalid environment variable format: {env_var}\")\n\n    return env, executable, args_list\n"
  },
  {
    "path": "backend/chainlit/message.py",
    "content": "import asyncio\nimport json\nimport time\nimport uuid\nfrom abc import ABC\nfrom typing import Dict, List, Optional, Union, cast\n\nfrom literalai.observability.step import MessageStepType\n\nfrom chainlit.action import Action\nfrom chainlit.chat_context import chat_context\nfrom chainlit.config import config\nfrom chainlit.context import context, local_steps\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import CustomElement, ElementBased\nfrom chainlit.logger import logger\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    AskActionResponse,\n    AskActionSpec,\n    AskElementResponse,\n    AskElementSpec,\n    AskFileResponse,\n    AskFileSpec,\n    AskSpec,\n    FileDict,\n)\nfrom chainlit.utils import utc_now\n\n\nclass MessageBase(ABC):\n    id: str\n    thread_id: str\n    author: str\n    content: str = \"\"\n    type: MessageStepType = \"assistant_message\"\n    streaming = False\n    created_at: Union[str, None] = None\n    fail_on_persist_error: bool = False\n    persisted = False\n    is_error = False\n    command: Optional[str] = None\n    modes: Optional[Dict[str, str]] = None\n    parent_id: Optional[str] = None\n    language: Optional[str] = None\n    metadata: Optional[Dict] = None\n    tags: Optional[List[str]] = None\n    wait_for_answer = False\n\n    def __post_init__(self) -> None:\n        self.thread_id = context.session.thread_id\n\n        previous_steps = local_steps.get() or []\n        parent_step = previous_steps[-1] if previous_steps else None\n        if parent_step:\n            self.parent_id = parent_step.id\n\n        if not getattr(self, \"id\", None):\n            self.id = str(uuid.uuid4())\n\n    @classmethod\n    def from_dict(self, _dict: StepDict):\n        type = _dict.get(\"type\", \"assistant_message\")\n        return Message(\n            id=_dict[\"id\"],\n            parent_id=_dict.get(\"parentId\"),\n            created_at=_dict[\"createdAt\"],\n            content=_dict[\"output\"],\n            author=_dict.get(\"name\", config.ui.name),\n            command=_dict.get(\"command\"),\n            modes=_dict.get(\"modes\"),\n            type=type,  # type: ignore\n            language=_dict.get(\"language\"),\n            metadata=_dict.get(\"metadata\", {}),\n        )\n\n    def to_dict(self) -> StepDict:\n        _dict: StepDict = {\n            \"id\": self.id,\n            \"threadId\": self.thread_id,\n            \"parentId\": self.parent_id,\n            \"createdAt\": self.created_at,\n            \"command\": self.command,\n            \"modes\": self.modes,\n            \"start\": self.created_at,\n            \"end\": self.created_at,\n            \"output\": self.content,\n            \"name\": self.author,\n            \"type\": self.type,\n            \"language\": self.language,\n            \"streaming\": self.streaming,\n            \"isError\": self.is_error,\n            \"waitForAnswer\": self.wait_for_answer,\n            \"metadata\": self.metadata or {},\n            \"tags\": self.tags,\n        }\n\n        return _dict\n\n    async def update(\n        self,\n    ):\n        \"\"\"\n        Update a message already sent to the UI.\n        \"\"\"\n\n        if self.streaming:\n            self.streaming = False\n\n        step_dict = self.to_dict()\n        chat_context.add(self)\n\n        data_layer = get_data_layer()\n        if data_layer:\n            try:\n                asyncio.create_task(data_layer.update_step(step_dict))\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist message update: {e!s}\")\n\n        await context.emitter.update_step(step_dict)\n\n        return True\n\n    async def remove(self):\n        \"\"\"\n        Remove a message already sent to the UI.\n        \"\"\"\n        chat_context.remove(self)\n        step_dict = self.to_dict()\n        data_layer = get_data_layer()\n        if data_layer:\n            try:\n                asyncio.create_task(data_layer.delete_step(step_dict[\"id\"]))\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist message deletion: {e!s}\")\n\n        await context.emitter.delete_step(step_dict)\n\n        return True\n\n    async def _create(self):\n        step_dict = self.to_dict()\n        data_layer = get_data_layer()\n        if data_layer and not self.persisted:\n            try:\n                asyncio.create_task(data_layer.create_step(step_dict))\n                self.persisted = True\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist message creation: {e!s}\")\n\n        return step_dict\n\n    async def send(self):\n        if not self.created_at:\n            self.created_at = utc_now()\n        if self.content is None:\n            self.content = \"\"\n\n        if config.code.author_rename:\n            self.author = await config.code.author_rename(self.author)\n\n        if self.streaming:\n            self.streaming = False\n\n        step_dict = await self._create()\n        chat_context.add(self)\n        await context.emitter.send_step(step_dict)\n\n        return self\n\n    async def stream_token(self, token: str, is_sequence=False):\n        \"\"\"\n        Sends a token to the UI. This is useful for streaming messages.\n        Once all tokens have been streamed, call .send() to end the stream and persist the message if persistence is enabled.\n        \"\"\"\n        if not token:\n            return\n\n        if is_sequence:\n            self.content = token\n        else:\n            self.content += token\n\n        assert self.id\n\n        if not self.streaming:\n            self.streaming = True\n            step_dict = self.to_dict()\n            await context.emitter.stream_start(step_dict)\n        else:\n            await context.emitter.send_token(\n                id=self.id, token=token, is_sequence=is_sequence\n            )\n\n\nclass Message(MessageBase):\n    \"\"\"\n    Send a message to the UI\n\n    Args:\n        content (Union[str, Dict]): The content of the message.\n        author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).\n        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.\n        actions (List[Action], optional): A list of actions to send with the message.\n        elements (List[ElementBased], optional): A list of elements to send with the message.\n    \"\"\"\n\n    def __init__(\n        self,\n        content: Union[str, Dict],\n        author: Optional[str] = None,\n        language: Optional[str] = None,\n        actions: Optional[List[Action]] = None,\n        elements: Optional[List[ElementBased]] = None,\n        type: MessageStepType = \"assistant_message\",\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n        id: Optional[str] = None,\n        parent_id: Optional[str] = None,\n        command: Optional[str] = None,\n        modes: Optional[Dict[str, str]] = None,\n        created_at: Union[str, None] = None,\n    ):\n        time.sleep(0.001)\n        self.language = language\n        if isinstance(content, dict):\n            try:\n                self.content = json.dumps(content, indent=4, ensure_ascii=False)\n                self.language = \"json\"\n            except TypeError:\n                self.content = str(content)\n                self.language = \"text\"\n        elif isinstance(content, str):\n            self.content = content\n        else:\n            self.content = str(content)\n            self.language = \"text\"\n\n        if id:\n            self.id = str(id)\n\n        if parent_id:\n            self.parent_id = str(parent_id)\n\n        if command:\n            self.command = str(command)\n\n        if modes:\n            self.modes = modes\n\n        if created_at:\n            self.created_at = created_at\n\n        self.metadata = metadata\n        self.tags = tags\n\n        self.author = author or config.ui.name\n        self.type = type\n        self.actions = actions if actions is not None else []\n        self.elements = elements if elements is not None else []\n\n        super().__post_init__()\n\n    async def send(self):\n        \"\"\"\n        Send the message to the UI and persist it in the cloud if a project ID is configured.\n        Return the ID of the message.\n        \"\"\"\n        await super().send()\n\n        # Create tasks for all actions and elements\n        tasks = [action.send(for_id=self.id) for action in self.actions]\n        tasks.extend(element.send(for_id=self.id) for element in self.elements)\n\n        # Run all tasks concurrently\n        await asyncio.gather(*tasks)\n\n        return self\n\n    async def update(self):\n        \"\"\"\n        Send the message to the UI and persist it in the cloud if a project ID is configured.\n        Return the ID of the message.\n        \"\"\"\n        await super().update()\n\n        # Update tasks for all actions and elements\n        tasks = [\n            action.send(for_id=self.id)\n            for action in self.actions\n            if action.forId is None\n        ]\n        tasks.extend(element.send(for_id=self.id) for element in self.elements)\n\n        # Run all tasks concurrently\n        await asyncio.gather(*tasks)\n\n        return True\n\n    async def remove_actions(self):\n        for action in self.actions:\n            await action.remove()\n\n\nclass ErrorMessage(MessageBase):\n    \"\"\"\n    Send an error message to the UI\n    If a project ID is configured, the message will be persisted in the cloud.\n\n    Args:\n        content (str): Text displayed above the upload button.\n        author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).\n    \"\"\"\n\n    def __init__(\n        self,\n        content: str,\n        author: str = config.ui.name,\n        fail_on_persist_error: bool = False,\n    ):\n        self.content = content\n        self.author = author\n        self.type = \"assistant_message\"\n        self.is_error = True\n        self.fail_on_persist_error = fail_on_persist_error\n\n        super().__post_init__()\n\n    async def send(self):\n        \"\"\"\n        Send the error message to the UI and persist it in the cloud if a project ID is configured.\n        Return the ID of the message.\n        \"\"\"\n        return await super().send()\n\n\nclass AskMessageBase(MessageBase):\n    async def remove(self):\n        removed = await super().remove()\n        if removed:\n            await context.emitter.clear(\"clear_ask\")\n\n\nclass AskUserMessage(AskMessageBase):\n    \"\"\"\n    Ask for the user input before continuing.\n    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.\n    If a project ID is configured, the message will be uploaded to the cloud storage.\n\n    Args:\n        content (str): The content of the prompt.\n        author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).\n        timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError.\n        raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time.\n    \"\"\"\n\n    def __init__(\n        self,\n        content: str,\n        author: str = config.ui.name,\n        type: MessageStepType = \"assistant_message\",\n        timeout: int = 60,\n        raise_on_timeout: bool = False,\n    ):\n        self.content = content\n        self.author = author\n        self.timeout = timeout\n        self.type = type\n        self.raise_on_timeout = raise_on_timeout\n\n        super().__post_init__()\n\n    async def send(self) -> Union[StepDict, None]:\n        \"\"\"\n        Sends the question to ask to the UI and waits for the reply.\n        \"\"\"\n        if not self.created_at:\n            self.created_at = utc_now()\n\n        if config.code.author_rename:\n            self.author = await config.code.author_rename(self.author)\n\n        if self.streaming:\n            self.streaming = False\n\n        self.wait_for_answer = True\n\n        step_dict = await self._create()\n\n        spec = AskSpec(type=\"text\", step_id=step_dict[\"id\"], timeout=self.timeout)\n\n        res = cast(\n            Union[None, StepDict],\n            await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),\n        )\n\n        self.wait_for_answer = False\n\n        return res\n\n\nclass AskFileMessage(AskMessageBase):\n    \"\"\"\n    Ask the user to upload a file before continuing.\n    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.\n    If a project ID is configured, the file will be uploaded to the cloud storage.\n\n    Args:\n        content (str): Text displayed above the upload button.\n        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\"]}.\n        max_size_mb (int, optional): Maximum size per file in MB. Maximum value is 100.\n        max_files (int, optional): Maximum number of files to upload. Maximum value is 10.\n        author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config).\n        timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError.\n        raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time.\n    \"\"\"\n\n    def __init__(\n        self,\n        content: str,\n        accept: Union[List[str], Dict[str, List[str]]],\n        max_size_mb=2,\n        max_files=1,\n        author=config.ui.name,\n        type: MessageStepType = \"assistant_message\",\n        timeout=90,\n        raise_on_timeout=False,\n    ):\n        self.content = content\n        self.max_size_mb = max_size_mb\n        self.max_files = max_files\n        self.accept = accept\n        self.type = type\n        self.author = author\n        self.timeout = timeout\n        self.raise_on_timeout = raise_on_timeout\n\n        super().__post_init__()\n\n    async def send(self) -> Union[List[AskFileResponse], None]:\n        \"\"\"\n        Sends the message to request a file from the user to the UI and waits for the reply.\n        \"\"\"\n        if not self.created_at:\n            self.created_at = utc_now()\n\n        if self.streaming:\n            self.streaming = False\n\n        if config.code.author_rename:\n            self.author = await config.code.author_rename(self.author)\n\n        self.wait_for_answer = True\n\n        step_dict = await self._create()\n\n        spec = AskFileSpec(\n            type=\"file\",\n            step_id=step_dict[\"id\"],\n            accept=self.accept,\n            max_size_mb=self.max_size_mb,\n            max_files=self.max_files,\n            timeout=self.timeout,\n        )\n\n        res = cast(\n            Union[None, List[FileDict]],\n            await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),\n        )\n\n        self.wait_for_answer = False\n\n        if res:\n            return [\n                AskFileResponse(\n                    id=r[\"id\"],\n                    name=r[\"name\"],\n                    path=str(r[\"path\"]),\n                    size=r[\"size\"],\n                    type=r[\"type\"],\n                )\n                for r in res\n            ]\n        else:\n            return None\n\n\nclass AskActionMessage(AskMessageBase):\n    \"\"\"\n    Ask the user to select an action before continuing.\n    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.\n    \"\"\"\n\n    def __init__(\n        self,\n        content: str,\n        actions: List[Action],\n        author=config.ui.name,\n        timeout=90,\n        raise_on_timeout=False,\n    ):\n        self.content = content\n        self.actions = actions\n        self.author = author\n        self.timeout = timeout\n        self.raise_on_timeout = raise_on_timeout\n\n        super().__post_init__()\n\n    async def send(self) -> Union[AskActionResponse, None]:\n        \"\"\"\n        Sends the question to ask to the UI and waits for the reply\n        \"\"\"\n        if not self.created_at:\n            self.created_at = utc_now()\n\n        if self.streaming:\n            self.streaming = False\n\n        if config.code.author_rename:\n            self.author = await config.code.author_rename(self.author)\n\n        self.wait_for_answer = True\n\n        step_dict = await self._create()\n\n        action_keys = []\n\n        for action in self.actions:\n            action_keys.append(action.id)\n            await action.send(for_id=str(step_dict[\"id\"]))\n\n        spec = AskActionSpec(\n            type=\"action\",\n            step_id=step_dict[\"id\"],\n            timeout=self.timeout,\n            keys=action_keys,\n        )\n\n        res = cast(\n            Union[AskActionResponse, None],\n            await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),\n        )\n\n        for action in self.actions:\n            await action.remove()\n        if res is None:\n            self.content = \"Timed out: no action was taken\"\n        else:\n            self.content = f\"**Selected:** {res['label']}\"\n\n        self.wait_for_answer = False\n\n        await self.update()\n\n        return res\n\n\nclass AskElementMessage(AskMessageBase):\n    \"\"\"Ask the user to submit a custom element.\"\"\"\n\n    def __init__(\n        self,\n        content: str,\n        element: CustomElement,\n        author=config.ui.name,\n        timeout=90,\n        raise_on_timeout=False,\n    ):\n        self.content = content\n        self.element = element\n        self.author = author\n        self.timeout = timeout\n        self.raise_on_timeout = raise_on_timeout\n\n        super().__post_init__()\n\n    async def send(self) -> Union[AskElementResponse, None]:\n        \"\"\"Send the custom element to the UI and wait for the reply.\"\"\"\n        if not self.created_at:\n            self.created_at = utc_now()\n\n        if self.streaming:\n            self.streaming = False\n\n        if config.code.author_rename:\n            self.author = await config.code.author_rename(self.author)\n\n        self.wait_for_answer = True\n\n        step_dict = await self._create()\n\n        await self.element.send(for_id=str(step_dict[\"id\"]))\n\n        spec = AskElementSpec(\n            type=\"element\",\n            step_id=step_dict[\"id\"],\n            timeout=self.timeout,\n            element_id=self.element.id,\n        )\n\n        res = cast(\n            Union[AskElementResponse, None],\n            await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout),\n        )\n\n        await self.element.remove()\n\n        if res is None:\n            self.content = \"Timed out\"\n        elif res.get(\"submitted\"):\n            self.content = \"Thanks for submitting\"\n        else:\n            self.content = \"Cancelled\"\n\n        self.wait_for_answer = False\n\n        await self.update()\n\n        return res\n"
  },
  {
    "path": "backend/chainlit/mistralai/__init__.py",
    "content": "import asyncio\nfrom typing import Union\n\nfrom literalai import ChatGeneration, CompletionGeneration\n\nfrom chainlit.context import get_context\nfrom chainlit.step import Step\nfrom chainlit.utils import timestamp_utc\n\n\ndef instrument_mistralai():\n    from literalai.instrumentation.mistralai import instrument_mistralai\n\n    def on_new_generation(\n        generation: Union[\"ChatGeneration\", \"CompletionGeneration\"], timing\n    ):\n        context = get_context()\n\n        parent_id = None\n        if context.current_step:\n            parent_id = context.current_step.id\n\n        step = Step(\n            name=generation.model if generation.model else generation.provider,\n            type=\"llm\",\n            parent_id=parent_id,\n        )\n        step.generation = generation\n        # Convert start/end time from seconds to milliseconds\n        step.start = (\n            timestamp_utc(timing.get(\"start\"))\n            if timing.get(\"start\", None) is not None\n            else None\n        )\n        step.end = (\n            timestamp_utc(timing.get(\"end\"))\n            if timing.get(\"end\", None) is not None\n            else None\n        )\n\n        if isinstance(generation, ChatGeneration):\n            step.input = generation.messages  # type: ignore\n            step.output = generation.message_completion  # type: ignore\n        else:\n            step.input = generation.prompt  # type: ignore\n            step.output = generation.completion  # type: ignore\n\n        asyncio.create_task(step.send())\n\n    instrument_mistralai(None, on_new_generation)\n"
  },
  {
    "path": "backend/chainlit/mode.py",
    "content": "\"\"\"Mode and ModeOption dataclasses for the Modes system.\n\nThe Modes system allows developers to define multiple picker categories\n(e.g., Model, Approach, Reasoning Effort) that users can select from\nin the chat composer.\n\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom typing import List, Optional\n\nfrom dataclasses_json import DataClassJsonMixin\n\n\n@dataclass\nclass ModeOption(DataClassJsonMixin):\n    \"\"\"A single selectable option within a Mode.\n\n    Attributes:\n        id: Unique identifier for this option (e.g., \"gpt-5\", \"planning\")\n        name: Display name shown in the UI (e.g., \"GPT-5\", \"Planning\")\n        description: Optional description shown in the dropdown\n        icon: Optional icon - can be a Lucide icon name, local path, or URL\n        default: Whether this is the default selected option for its mode\n    \"\"\"\n\n    id: str\n    name: str\n    description: Optional[str] = None\n    icon: Optional[str] = None\n    default: bool = False\n\n\n@dataclass\nclass Mode(DataClassJsonMixin):\n    \"\"\"A category of options the user can select from.\n\n    Each Mode represents a picker dropdown in the chat composer.\n    Users select exactly one option per mode.\n\n    Attributes:\n        id: Unique identifier for this mode (e.g., \"llm\", \"approach\")\n        name: Display name shown in the UI (e.g., \"Model\", \"Approach\")\n        options: List of available options for this mode\n    \"\"\"\n\n    id: str\n    name: str\n    options: List[ModeOption] = field(default_factory=list)\n\n    def get_default_option(self) -> Optional[ModeOption]:\n        \"\"\"Get the default option for this mode, or the first option if none is default.\"\"\"\n        for option in self.options:\n            if option.default:\n                return option\n        return self.options[0] if self.options else None\n\n    def get_option_by_id(self, option_id: str) -> Optional[ModeOption]:\n        \"\"\"Get an option by its ID.\"\"\"\n        for option in self.options:\n            if option.id == option_id:\n                return option\n        return None\n"
  },
  {
    "path": "backend/chainlit/oauth_providers.py",
    "content": "import base64\nimport os\nimport urllib.parse\nfrom typing import Dict, List, Optional, Tuple\n\nimport httpx\nfrom fastapi import HTTPException\n\nfrom chainlit.secret import random_secret\nfrom chainlit.user import User\n\nACCESS_TOKEN_MISSING = \"Access token missing in the response\"\n\n\nclass OAuthProvider:\n    id: str\n    env: List[str]\n    client_id: str\n    client_secret: str\n    authorize_url: str\n    authorize_params: Dict[str, str]\n    default_prompt: Optional[str] = None\n\n    def is_configured(self):\n        return all([os.environ.get(env) for env in self.env])\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        raise NotImplementedError\n\n    async def get_token(self, code: str, url: str) -> str:\n        raise NotImplementedError\n\n    async def get_user_info(self, token: str) -> Tuple[Dict[str, str], User]:\n        raise NotImplementedError\n\n    def get_env_prefix(self) -> str:\n        \"\"\"Return environment prefix, like AZURE_AD.\"\"\"\n\n        return self.id.replace(\"-\", \"_\").upper()\n\n    def get_prompt(self) -> Optional[str]:\n        \"\"\"Return OAuth prompt param.\"\"\"\n        if prompt := os.environ.get(f\"OAUTH_{self.get_env_prefix()}_PROMPT\"):\n            return prompt\n\n        if prompt := os.environ.get(\"OAUTH_PROMPT\"):\n            return prompt\n\n        return self.default_prompt\n\n\nclass GithubOAuthProvider(OAuthProvider):\n    id = \"github\"\n    env = [\"OAUTH_GITHUB_CLIENT_ID\", \"OAUTH_GITHUB_CLIENT_SECRET\"]\n    authorize_url = os.environ.get(\n        \"OAUTH_GITHUB_AUTH_URL\", \"https://github.com/login/oauth/authorize\"\n    )\n    token_url = os.environ.get(\n        \"OAUTH_GITHUB_TOKEN_URL\", \"https://github.com/login/oauth/access_token\"\n    )\n    user_info_url = os.environ.get(\n        \"OAUTH_GITHUB_USER_INFO_URL\", \"https://api.github.com/user\"\n    )\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_GITHUB_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_GITHUB_CLIENT_SECRET\")\n        self.authorize_params = {\n            \"scope\": \"user:email\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> Dict[str, List[str]]:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                self.token_url,\n                data=payload,\n            )\n            response.raise_for_status()\n            return urllib.parse.parse_qs(response.text)\n\n    async def get_token(self, code: str, url: str):\n        content = await self.get_raw_token_response(code, url)\n        token = content.get(\"access_token\", [\"\"])[0]\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            user_response = await client.get(\n                self.user_info_url,\n                headers={\"Authorization\": f\"token {token}\"},\n            )\n            user_response.raise_for_status()\n            github_user = user_response.json()\n\n            emails_response = await client.get(\n                urllib.parse.urljoin(self.user_info_url + \"/\", \"emails\"),\n                headers={\"Authorization\": f\"token {token}\"},\n            )\n            emails_response.raise_for_status()\n            emails = emails_response.json()\n\n            github_user.update({\"emails\": emails})\n            user = User(\n                identifier=github_user[\"login\"],\n                metadata={\"image\": github_user[\"avatar_url\"], \"provider\": \"github\"},\n            )\n            return (github_user, user)\n\n\nclass GoogleOAuthProvider(OAuthProvider):\n    id = \"google\"\n    env = [\"OAUTH_GOOGLE_CLIENT_ID\", \"OAUTH_GOOGLE_CLIENT_SECRET\"]\n    authorize_url = \"https://accounts.google.com/o/oauth2/v2/auth\"\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_GOOGLE_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_GOOGLE_CLIENT_SECRET\")\n        self.authorize_params = {\n            \"scope\": \"https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email\",\n            \"response_type\": \"code\",\n            \"access_type\": \"offline\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                \"https://oauth2.googleapis.com/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json = await self.get_raw_token_response(code, url)\n        token = json.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://www.googleapis.com/userinfo/v2/me\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            google_user = response.json()\n            user = User(\n                identifier=google_user[\"email\"],\n                metadata={\"image\": google_user[\"picture\"], \"provider\": \"google\"},\n            )\n            return (google_user, user)\n\n\nclass AzureADOAuthProvider(OAuthProvider):\n    id = \"azure-ad\"\n    env = [\n        \"OAUTH_AZURE_AD_CLIENT_ID\",\n        \"OAUTH_AZURE_AD_CLIENT_SECRET\",\n        \"OAUTH_AZURE_AD_TENANT_ID\",\n    ]\n    authorize_url = (\n        f\"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/authorize\"\n        if os.environ.get(\"OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT\")\n        else \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\"\n    )\n    token_url = (\n        f\"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/token\"\n        if os.environ.get(\"OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT\")\n        else \"https://login.microsoftonline.com/common/oauth2/v2.0/token\"\n    )\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_AZURE_AD_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_AZURE_AD_CLIENT_SECRET\")\n        self.authorize_params = {\n            \"tenant\": os.environ.get(\"OAUTH_AZURE_AD_TENANT_ID\"),\n            \"response_type\": \"code\",\n            \"scope\": \"https://graph.microsoft.com/User.Read offline_access\",\n            \"response_mode\": \"query\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                self.token_url,\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json = await self.get_raw_token_response(code, url)\n\n        token = json[\"access_token\"]\n        refresh_token = json.get(\"refresh_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        self._refresh_token = refresh_token\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://graph.microsoft.com/v1.0/me\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n\n            azure_user = response.json()\n\n            try:\n                photo_response = await client.get(\n                    \"https://graph.microsoft.com/v1.0/me/photos/48x48/$value\",\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                )\n                photo_data = await photo_response.aread()\n                base64_image = base64.b64encode(photo_data)\n                azure_user[\"image\"] = (\n                    f\"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}\"\n                )\n            except Exception:\n                # Ignore errors getting the photo\n                pass\n\n            user = User(\n                identifier=azure_user[\"userPrincipalName\"],\n                metadata={\n                    \"image\": azure_user.get(\"image\"),\n                    \"provider\": \"azure-ad\",\n                    \"refresh_token\": getattr(self, \"_refresh_token\", None),\n                },\n            )\n            return (azure_user, user)\n\n\nclass AzureADHybridOAuthProvider(OAuthProvider):\n    id = \"azure-ad-hybrid\"\n    env = [\n        \"OAUTH_AZURE_AD_HYBRID_CLIENT_ID\",\n        \"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET\",\n        \"OAUTH_AZURE_AD_HYBRID_TENANT_ID\",\n    ]\n    authorize_url = (\n        f\"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/authorize\"\n        if os.environ.get(\"OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT\")\n        else \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\"\n    )\n    token_url = (\n        f\"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/token\"\n        if os.environ.get(\"OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT\")\n        else \"https://login.microsoftonline.com/common/oauth2/v2.0/token\"\n    )\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_AZURE_AD_HYBRID_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET\")\n        nonce = random_secret(16)\n        self.authorize_params = {\n            \"tenant\": os.environ.get(\"OAUTH_AZURE_AD_HYBRID_TENANT_ID\"),\n            \"response_type\": \"code id_token\",\n            \"scope\": \"https://graph.microsoft.com/User.Read https://graph.microsoft.com/openid offline_access\",\n            \"response_mode\": \"form_post\",\n            \"nonce\": nonce,\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                self.token_url,\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json = await self.get_raw_token_response(code, url)\n\n        token = json[\"access_token\"]\n        refresh_token = json.get(\"refresh_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        self._refresh_token = refresh_token\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://graph.microsoft.com/v1.0/me\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n\n            azure_user = response.json()\n\n            try:\n                photo_response = await client.get(\n                    \"https://graph.microsoft.com/v1.0/me/photos/48x48/$value\",\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                )\n                photo_data = await photo_response.aread()\n                base64_image = base64.b64encode(photo_data)\n                azure_user[\"image\"] = (\n                    f\"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}\"\n                )\n            except Exception:\n                # Ignore errors getting the photo\n                pass\n\n            user = User(\n                identifier=azure_user[\"userPrincipalName\"],\n                metadata={\n                    \"image\": azure_user.get(\"image\"),\n                    \"provider\": \"azure-ad\",\n                    \"refresh_token\": getattr(self, \"_refresh_token\", None),\n                },\n            )\n            return (azure_user, user)\n\n\nclass OktaOAuthProvider(OAuthProvider):\n    id = \"okta\"\n    env = [\n        \"OAUTH_OKTA_CLIENT_ID\",\n        \"OAUTH_OKTA_CLIENT_SECRET\",\n        \"OAUTH_OKTA_DOMAIN\",\n    ]\n    # Avoid trailing slash in domain if supplied\n    domain = f\"https://{os.environ.get('OAUTH_OKTA_DOMAIN', '').rstrip('/')}\"\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_OKTA_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_OKTA_CLIENT_SECRET\")\n        self.authorization_server_id = os.environ.get(\n            \"OAUTH_OKTA_AUTHORIZATION_SERVER_ID\", \"\"\n        )\n        self.authorize_url = (\n            f\"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/authorize\"\n        )\n        self.authorize_params = {\n            \"response_type\": \"code\",\n            \"scope\": \"openid profile email\",\n            \"response_mode\": \"query\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    def get_authorization_server_path(self):\n        if not self.authorization_server_id:\n            return \"/default\"\n        if self.authorization_server_id == \"false\":\n            return \"\"\n        return f\"/{self.authorization_server_id}\"\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                f\"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json_data = await self.get_raw_token_response(code, url)\n        token = json_data.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/userinfo\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            okta_user = response.json()\n\n            user = User(\n                identifier=okta_user.get(\"email\"),\n                metadata={\"image\": \"\", \"provider\": \"okta\"},\n            )\n            return (okta_user, user)\n\n\nclass Auth0OAuthProvider(OAuthProvider):\n    id = \"auth0\"\n    env = [\"OAUTH_AUTH0_CLIENT_ID\", \"OAUTH_AUTH0_CLIENT_SECRET\", \"OAUTH_AUTH0_DOMAIN\"]\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_AUTH0_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_AUTH0_CLIENT_SECRET\")\n        # Ensure that the domain does not have a trailing slash\n        self.domain = f\"https://{os.environ.get('OAUTH_AUTH0_DOMAIN', '').rstrip('/')}\"\n        self.original_domain = (\n            f\"https://{os.environ.get('OAUTH_AUTH0_ORIGINAL_DOMAIN').rstrip('/')}\"\n            if os.environ.get(\"OAUTH_AUTH0_ORIGINAL_DOMAIN\")\n            else self.domain\n        )\n\n        self.authorize_url = f\"{self.domain}/authorize\"\n\n        self.authorize_params = {\n            \"response_type\": \"code\",\n            \"scope\": \"openid profile email\",\n            \"audience\": f\"{self.original_domain}/userinfo\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                f\"{self.domain}/oauth/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json_content = await self.get_raw_token_response(code, url)\n        token = json_content.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{self.original_domain}/userinfo\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            auth0_user = response.json()\n            user = User(\n                identifier=auth0_user.get(\"email\"),\n                metadata={\n                    \"image\": auth0_user.get(\"picture\", \"\"),\n                    \"provider\": \"auth0\",\n                },\n            )\n            return (auth0_user, user)\n\n\nclass DescopeOAuthProvider(OAuthProvider):\n    id = \"descope\"\n    env = [\"OAUTH_DESCOPE_CLIENT_ID\", \"OAUTH_DESCOPE_CLIENT_SECRET\"]\n    # Ensure that the domain does not have a trailing slash\n    domain = \"https://api.descope.com/oauth2/v1\"\n\n    authorize_url = f\"{domain}/authorize\"\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_DESCOPE_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_DESCOPE_CLIENT_SECRET\")\n        self.authorize_params = {\n            \"response_type\": \"code\",\n            \"scope\": \"openid profile email\",\n            \"audience\": f\"{self.domain}/userinfo\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                f\"{self.domain}/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json_content = await self.get_raw_token_response(code, url)\n        token = json_content.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{self.domain}/userinfo\", headers={\"Authorization\": f\"Bearer {token}\"}\n            )\n            response.raise_for_status()  # This will raise an exception for 4xx/5xx responses\n            descope_user = response.json()\n\n            user = User(\n                identifier=descope_user.get(\"email\"),\n                metadata={\"image\": \"\", \"provider\": \"descope\"},\n            )\n            return (descope_user, user)\n\n\nclass AWSCognitoOAuthProvider(OAuthProvider):\n    id = \"aws-cognito\"\n    env = [\n        \"OAUTH_COGNITO_CLIENT_ID\",\n        \"OAUTH_COGNITO_CLIENT_SECRET\",\n        \"OAUTH_COGNITO_DOMAIN\",\n    ]\n    authorize_url = f\"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/login\"\n    token_url = f\"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/token\"\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_COGNITO_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_COGNITO_CLIENT_SECRET\")\n        self.scopes = os.environ.get(\"OAUTH_COGNITO_SCOPE\", \"openid profile email\")\n        self.authorize_params = {\n            \"response_type\": \"code\",\n            \"client_id\": self.client_id,\n            \"scope\": self.scopes,\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                self.token_url,\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json = await self.get_raw_token_response(code, url)\n        token = json.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        user_info_url = (\n            f\"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/userInfo\"\n        )\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                user_info_url,\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n\n            cognito_user = response.json()\n\n            # Customize user metadata as needed\n            user = User(\n                identifier=cognito_user[\"email\"],\n                metadata={\n                    \"image\": cognito_user.get(\"picture\", \"\"),\n                    \"provider\": \"aws-cognito\",\n                },\n            )\n            return (cognito_user, user)\n\n\nclass GitlabOAuthProvider(OAuthProvider):\n    id = \"gitlab\"\n    env = [\n        \"OAUTH_GITLAB_CLIENT_ID\",\n        \"OAUTH_GITLAB_CLIENT_SECRET\",\n        \"OAUTH_GITLAB_DOMAIN\",\n    ]\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_GITLAB_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_GITLAB_CLIENT_SECRET\")\n        # Ensure that the domain does not have a trailing slash\n        self.domain = f\"https://{os.environ.get('OAUTH_GITLAB_DOMAIN', '').rstrip('/')}\"\n\n        self.authorize_url = f\"{self.domain}/oauth/authorize\"\n\n        self.authorize_params = {\n            \"scope\": \"openid profile email\",\n            \"response_type\": \"code\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                f\"{self.domain}/oauth/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json_content = await self.get_raw_token_response(code, url)\n        token = json_content.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{self.domain}/oauth/userinfo\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            gitlab_user = response.json()\n            user = User(\n                identifier=gitlab_user.get(\"email\"),\n                metadata={\n                    \"image\": gitlab_user.get(\"picture\", \"\"),\n                    \"provider\": \"gitlab\",\n                },\n            )\n            return (gitlab_user, user)\n\n\nclass KeycloakOAuthProvider(OAuthProvider):\n    env = [\n        \"OAUTH_KEYCLOAK_CLIENT_ID\",\n        \"OAUTH_KEYCLOAK_CLIENT_SECRET\",\n        \"OAUTH_KEYCLOAK_REALM\",\n        \"OAUTH_KEYCLOAK_BASE_URL\",\n    ]\n    id = os.environ.get(\"OAUTH_KEYCLOAK_NAME\", \"keycloak\")\n\n    def __init__(self):\n        self.refresh_token = None\n        self.client_id = os.environ.get(\"OAUTH_KEYCLOAK_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_KEYCLOAK_CLIENT_SECRET\")\n        self.realm = os.environ.get(\"OAUTH_KEYCLOAK_REALM\")\n        self.base_url = os.environ.get(\"OAUTH_KEYCLOAK_BASE_URL\")\n        self.authorize_url = (\n            f\"{self.base_url}/realms/{self.realm}/protocol/openid-connect/auth\"\n        )\n\n        self.authorize_params = {\n            \"scope\": \"profile email openid\",\n            \"response_type\": \"code\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(\n                f\"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token\",\n                data=payload,\n            )\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str):\n        json = await self.get_raw_token_response(code, url)\n        token = json.get(\"access_token\")\n        refresh_token = json.get(\"refresh_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        self.refresh_token = refresh_token\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{self.base_url}/realms/{self.realm}/protocol/openid-connect/userinfo\",\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            kc_user = response.json()\n            user = User(\n                identifier=kc_user[\"email\"],\n                metadata={\"provider\": \"keycloak\"},\n            )\n            return (kc_user, user)\n\n\nclass GenericOAuthProvider(OAuthProvider):\n    env = [\n        \"OAUTH_GENERIC_CLIENT_ID\",\n        \"OAUTH_GENERIC_CLIENT_SECRET\",\n        \"OAUTH_GENERIC_AUTH_URL\",\n        \"OAUTH_GENERIC_TOKEN_URL\",\n        \"OAUTH_GENERIC_USER_INFO_URL\",\n        \"OAUTH_GENERIC_SCOPES\",\n    ]\n    id = os.environ.get(\"OAUTH_GENERIC_NAME\", \"generic\")\n\n    def __init__(self):\n        self.client_id = os.environ.get(\"OAUTH_GENERIC_CLIENT_ID\")\n        self.client_secret = os.environ.get(\"OAUTH_GENERIC_CLIENT_SECRET\")\n        self.authorize_url = os.environ.get(\"OAUTH_GENERIC_AUTH_URL\")\n        self.token_url = os.environ.get(\"OAUTH_GENERIC_TOKEN_URL\")\n        self.user_info_url = os.environ.get(\"OAUTH_GENERIC_USER_INFO_URL\")\n        self.scopes = os.environ.get(\"OAUTH_GENERIC_SCOPES\")\n        self.user_identifier = os.environ.get(\"OAUTH_GENERIC_USER_IDENTIFIER\", \"email\")\n\n        self.authorize_params = {\n            \"scope\": self.scopes,\n            \"response_type\": \"code\",\n        }\n\n        if prompt := self.get_prompt():\n            self.authorize_params[\"prompt\"] = prompt\n\n    async def get_raw_token_response(self, code: str, url: str) -> dict:\n        payload = {\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n            \"code\": code,\n            \"grant_type\": \"authorization_code\",\n            \"redirect_uri\": url,\n        }\n        async with httpx.AsyncClient() as client:\n            response = await client.post(self.token_url, data=payload)\n            response.raise_for_status()\n            return response.json()\n\n    async def get_token(self, code: str, url: str) -> str:\n        json = await self.get_raw_token_response(code, url)\n        token = json.get(\"access_token\")\n        if not token:\n            raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING)\n        return token\n\n    async def get_user_info(self, token: str):\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                self.user_info_url,\n                headers={\"Authorization\": f\"Bearer {token}\"},\n            )\n            response.raise_for_status()\n            server_user = response.json()\n            user = User(\n                identifier=server_user.get(self.user_identifier),\n                metadata={\n                    \"provider\": self.id,\n                },\n            )\n            return (server_user, user)\n\n\nproviders = [\n    GithubOAuthProvider(),\n    GoogleOAuthProvider(),\n    AzureADOAuthProvider(),\n    AzureADHybridOAuthProvider(),\n    OktaOAuthProvider(),\n    Auth0OAuthProvider(),\n    DescopeOAuthProvider(),\n    AWSCognitoOAuthProvider(),\n    GitlabOAuthProvider(),\n    KeycloakOAuthProvider(),\n    GenericOAuthProvider(),\n]\n\n\ndef get_oauth_provider(provider: str) -> Optional[OAuthProvider]:\n    for p in providers:\n        if p.id == provider:\n            return p\n    return None\n\n\ndef get_configured_oauth_providers():\n    return [p.id for p in providers if p.is_configured()]\n"
  },
  {
    "path": "backend/chainlit/openai/__init__.py",
    "content": "import asyncio\nfrom typing import Union\n\nfrom literalai import ChatGeneration, CompletionGeneration\n\nfrom chainlit.context import local_steps\nfrom chainlit.step import Step\nfrom chainlit.utils import check_module_version, timestamp_utc\n\n\ndef instrument_openai():\n    if not check_module_version(\"openai\", \"1.0.0\"):\n        raise ValueError(\n            \"Expected OpenAI version >= 1.0.0. Run `pip install openai --upgrade`\"\n        )\n\n    from literalai.instrumentation.openai import instrument_openai\n\n    def on_new_generation(\n        generation: Union[\"ChatGeneration\", \"CompletionGeneration\"], timing\n    ):\n        previous_steps = local_steps.get()\n\n        parent_id = previous_steps[-1].id if previous_steps else None\n\n        step = Step(\n            name=generation.model if generation.model else generation.provider,\n            type=\"llm\",\n            parent_id=parent_id,\n        )\n        step.generation = generation\n        # Convert start/end time from seconds to milliseconds\n        step.start = (\n            timestamp_utc(timing.get(\"start\"))\n            if timing.get(\"start\", None) is not None\n            else None\n        )\n        step.end = (\n            timestamp_utc(timing.get(\"end\"))\n            if timing.get(\"end\", None) is not None\n            else None\n        )\n\n        if isinstance(generation, ChatGeneration):\n            step.input = generation.messages  # type: ignore\n            step.output = generation.message_completion  # type: ignore\n        else:\n            step.input = generation.prompt  # type: ignore\n            step.output = generation.completion  # type: ignore\n\n        asyncio.create_task(step.send())\n\n    instrument_openai(None, on_new_generation)\n"
  },
  {
    "path": "backend/chainlit/py.typed",
    "content": ""
  },
  {
    "path": "backend/chainlit/sample/hello.py",
    "content": "# This is a simple example of a chainlit app.\n\nfrom chainlit import AskUserMessage, Message, on_chat_start\n\n\n@on_chat_start\nasync def main():\n    res = await AskUserMessage(content=\"What is your name?\", timeout=30).send()\n    if res:\n        await Message(\n            content=f\"Your name is: {res['output']}.\\nChainlit installation is working!\\nYou can now start building your own chainlit apps!\",\n        ).send()\n"
  },
  {
    "path": "backend/chainlit/sample/starters_demo.py",
    "content": "from typing import Optional\n\nimport chainlit as cl\n\n\n@cl.set_starter_categories\nasync def starter_categories(user: Optional[cl.User] = None):\n    return [\n        cl.StarterCategory(\n            label=\"Creative\",\n            icon=\"https://cdn-icons-png.flaticon.com/512/3094/3094837.png\",\n            starters=[\n                cl.Starter(\n                    label=\"Write a poem about nature\",\n                    message=\"Write a poem about nature\",\n                ),\n                cl.Starter(\n                    label=\"Create a short story\",\n                    message=\"Create a short story about adventure\",\n                ),\n                cl.Starter(\n                    label=\"Generate a creative name\",\n                    message=\"Generate creative names for a tech startup\",\n                ),\n            ],\n        ),\n        cl.StarterCategory(\n            label=\"Learning\",\n            icon=\"https://cdn-icons-png.flaticon.com/512/3976/3976625.png\",\n            starters=[\n                cl.Starter(\n                    label=\"Explain a complex topic\",\n                    message=\"Explain quantum computing in simple terms\",\n                ),\n                cl.Starter(\n                    label=\"Help me learn a language\",\n                    message=\"Teach me basic French phrases\",\n                ),\n            ],\n        ),\n        cl.StarterCategory(\n            label=\"Productivity\",\n            icon=\"https://cdn-icons-png.flaticon.com/512/1055/1055646.png\",\n            starters=[\n                cl.Starter(\n                    label=\"Summarize a topic\",\n                    message=\"Summarize the key points of machine learning\",\n                ),\n                cl.Starter(\n                    label=\"Create a plan\", message=\"Help me create a weekly study plan\"\n                ),\n            ],\n        ),\n    ]\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    await cl.Message(f\"You said: {msg.content}\").send()\n"
  },
  {
    "path": "backend/chainlit/secret.py",
    "content": "import secrets\nimport string\n\n# Using punctuation, without chars that can break in the cli (quotes, backslash, backtick...)\nchars = string.ascii_letters + string.digits + \"$%*,-./:=>?@^_~\"\n\n\ndef random_secret(length: int = 64):\n    return \"\".join(secrets.choice(chars) for i in range(length))\n"
  },
  {
    "path": "backend/chainlit/semantic_kernel/__init__.py",
    "content": "from collections.abc import Awaitable, Callable\nfrom typing import TYPE_CHECKING, Any\n\nfrom pydantic import BaseModel\n\nfrom chainlit import Step\n\nif TYPE_CHECKING:\n    from semantic_kernel import Kernel\n    from semantic_kernel.filters import FunctionInvocationContext\n    from semantic_kernel.functions import KernelArguments\n\n\nclass SemanticKernelFilter(BaseModel):\n    \"\"\"Semantic Kernel Filter for Chainlit.\n\n    This filter wraps any function calls that are executed and will capture the input and output of that function\n    as a Chainlit Step.\n\n    You can pass your kernel into the constructor, or you can call `add_to_kernel` later.\n\n    Args:\n        excluded_plugins: a list of plugin_names that will be excluded from displaying steps.\n        excluded_functions: a list of function names that will be excluded from displaying steps.\n        kernel: the Kernel to add the filter to. If not provided, you can call `add_to_kernel` later.\n\n    Methods:\n        add_to_kernel: this method takes a Kernel and adds the filter to that kernel.\n        parse_arguments: this method is called with KernelArguments used for the function\n            it can be subclassed to customize how to represent the input arguments.\n\n    Example::\n\n        filter = SemanticKernelFilter(kernel=kernel)\n\n        # or when you create your kernel later on:\n\n        filter = SemanticKernelFilter()\n        # ...\n        # other code, including kernel creation.\n        # ...\n        filter.add_to_kernel(kernel)\n    \"\"\"\n\n    excluded_plugins: list[str] | None = None\n    excluded_functions: list[str] | None = None\n\n    def __init__(\n        self,\n        excluded_plugins: list[str] | None = None,\n        excluded_functions: list[str] | None = None,\n        *,\n        kernel: \"Kernel | None\" = None,\n    ) -> None:\n        super().__init__(\n            excluded_plugins=excluded_plugins, excluded_functions=excluded_functions\n        )\n        if kernel:\n            self.add_to_kernel(kernel)\n\n    def add_to_kernel(self, kernel: \"Kernel\") -> None:\n        \"\"\"Adds the filter to the provided kernel.\n\n        Args:\n            kernel: the Kernel to add the filter to.\n        \"\"\"\n        kernel.add_filter(\"function_invocation\", self._function_invocation_filter)  # type: ignore[arg-type]\n\n    def parse_arguments(self, arguments: \"KernelArguments\") -> dict[str, Any] | str:\n        \"\"\"Parse the KernelArguments used for the function.\n\n        This function can be subclassed to easily adopt how the input arguments are displayed.\n\n        Args:\n            arguments: KernelArguments\n\n        Returns:\n            a dict or string with the input.\n        \"\"\"\n        if len(arguments) == 0:\n            return \"\"\n        input_dict = {}\n        for key, value in arguments.items():\n            if isinstance(value, BaseModel):\n                input_dict[key] = value.model_dump(exclude_none=True, by_alias=True)\n            else:\n                input_dict[key] = value\n        return input_dict\n\n    async def _function_invocation_filter(\n        self,\n        context: \"FunctionInvocationContext\",\n        next: Callable[[\"FunctionInvocationContext\"], Awaitable[None]],\n    ):\n        if (\n            self.excluded_plugins\n            and context.function.plugin_name in self.excluded_plugins\n        ) or (\n            self.excluded_functions and context.function.name in self.excluded_functions\n        ):\n            await next(context)\n            return\n        async with Step(\n            type=\"tool\", name=context.function.fully_qualified_name\n        ) as step:\n            step.input = self.parse_arguments(context.arguments)\n            await step.send()\n            await next(context)\n            if context.result:\n                step.output = context.result.value\n            await step.update()\n"
  },
  {
    "path": "backend/chainlit/server.py",
    "content": "import asyncio\nimport fnmatch\nimport glob\nimport json\nimport mimetypes\nimport os\nimport re\nimport shutil\nimport urllib.parse\nimport webbrowser\nfrom contextlib import AsyncExitStack, asynccontextmanager\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, List, Optional, Union, cast\n\nimport socketio\nfrom fastapi import (\n    APIRouter,\n    Depends,\n    FastAPI,\n    Form,\n    HTTPException,\n    Query,\n    Request,\n    Response,\n    UploadFile,\n    status,\n)\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse\nfrom fastapi.security import OAuth2PasswordRequestForm\nfrom starlette.datastructures import URL\nfrom starlette.middleware.cors import CORSMiddleware\nfrom starlette.types import Receive, Scope, Send\nfrom typing_extensions import Annotated\nfrom watchfiles import awatch\n\nfrom chainlit.auth import create_jwt, decode_jwt, get_configuration, get_current_user\nfrom chainlit.auth.cookie import (\n    clear_auth_cookie,\n    clear_oauth_state_cookie,\n    set_auth_cookie,\n    set_oauth_state_cookie,\n    validate_oauth_state_cookie,\n)\nfrom chainlit.config import (\n    APP_ROOT,\n    BACKEND_ROOT,\n    DEFAULT_HOST,\n    FILES_DIRECTORY,\n    PACKAGE_ROOT,\n    ChainlitConfig,\n    config,\n    load_module,\n    public_dir,\n    reload_config,\n)\nfrom chainlit.data import get_data_layer\nfrom chainlit.data.acl import is_thread_author\nfrom chainlit.logger import logger\nfrom chainlit.markdown import get_markdown_str\nfrom chainlit.oauth_providers import get_oauth_provider\nfrom chainlit.secret import random_secret\nfrom chainlit.types import (\n    AskFileSpec,\n    CallActionRequest,\n    ConnectMCPRequest,\n    DeleteFeedbackRequest,\n    DeleteThreadRequest,\n    DisconnectMCPRequest,\n    ElementRequest,\n    GetThreadsRequest,\n    ShareThreadRequest,\n    Theme,\n    UpdateFeedbackRequest,\n    UpdateThreadRequest,\n)\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.utils import utc_now\n\nfrom ._utils import is_path_inside\n\nif TYPE_CHECKING:\n    from chainlit.element import CustomElement, ElementDict\n\nmimetypes.add_type(\"application/javascript\", \".js\")\nmimetypes.add_type(\"text/css\", \".css\")\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"Context manager to handle app start and shutdown.\"\"\"\n    if config.code.on_app_startup:\n        await config.code.on_app_startup()\n\n    host = config.run.host\n    port = config.run.port\n    root_path = os.getenv(\"CHAINLIT_ROOT_PATH\", \"\")\n    scheme = \"https\" if config.run.ssl_cert else \"http\"\n\n    if host == DEFAULT_HOST:\n        url = f\"{scheme}://localhost:{port}{root_path}\"\n    else:\n        url = f\"{scheme}://{host}:{port}{root_path}\"\n\n    logger.info(f\"Your app is available at {url}\")\n\n    if not config.run.headless:\n        # Add a delay before opening the browser\n        await asyncio.sleep(1)\n        webbrowser.open(url)\n\n    watch_task = None\n    stop_event = asyncio.Event()\n\n    if config.run.watch:\n\n        async def watch_files_for_changes():\n            extensions = [\".py\"]\n            files = [\"chainlit.md\", \"config.toml\"]\n            async for changes in awatch(config.root, stop_event=stop_event):\n                for change_type, file_path in changes:\n                    file_name = os.path.basename(file_path)\n                    file_ext = os.path.splitext(file_name)[1]\n\n                    if file_ext.lower() in extensions or file_name.lower() in files:\n                        logger.info(\n                            f\"File {change_type.name}: {file_name}. Reloading app...\"\n                        )\n\n                        try:\n                            reload_config()\n                        except Exception as e:\n                            logger.error(f\"Error reloading config: {e}\")\n                            break\n\n                        # Reload the module if the module name is specified in the config\n                        if config.run.module_name:\n                            try:\n                                load_module(config.run.module_name, force_refresh=True)\n                            except Exception as e:\n                                logger.error(f\"Error reloading module: {e}\")\n\n                        await asyncio.sleep(1)\n                        await sio.emit(\"reload\", {})\n\n                        break\n\n        watch_task = asyncio.create_task(watch_files_for_changes())\n\n    discord_task = None\n\n    if discord_bot_token := os.environ.get(\"DISCORD_BOT_TOKEN\"):\n        from chainlit.discord.app import client\n\n        discord_task = asyncio.create_task(client.start(discord_bot_token))\n\n    slack_task = None\n\n    # Slack Socket Handler if env variable SLACK_WEBSOCKET_TOKEN is set\n    if os.environ.get(\"SLACK_BOT_TOKEN\") and os.environ.get(\"SLACK_WEBSOCKET_TOKEN\"):\n        from chainlit.slack.app import start_socket_mode\n\n        slack_task = asyncio.create_task(start_socket_mode())\n\n    try:\n        yield\n    finally:\n        try:\n            if config.code.on_app_shutdown:\n                await config.code.on_app_shutdown()\n\n            if watch_task:\n                stop_event.set()\n                watch_task.cancel()\n                await watch_task\n\n            if discord_task:\n                discord_task.cancel()\n                await discord_task\n\n            if slack_task:\n                slack_task.cancel()\n                await slack_task\n\n            if data_layer := get_data_layer():\n                await data_layer.close()\n        except asyncio.exceptions.CancelledError:\n            pass\n\n        if FILES_DIRECTORY.is_dir():\n            shutil.rmtree(FILES_DIRECTORY)\n\n        # Force exit the process to avoid potential AnyIO threads still running\n        os._exit(0)\n\n\ndef get_build_dir(local_target: str, packaged_target: str) -> str:\n    \"\"\"\n    Get the build directory based on the UI build strategy.\n\n    Args:\n        local_target (str): The local target directory.\n        packaged_target (str): The packaged target directory.\n\n    Returns:\n        str: The build directory\n    \"\"\"\n\n    local_build_dir = os.path.join(PACKAGE_ROOT, local_target, \"dist\")\n    packaged_build_dir = os.path.join(BACKEND_ROOT, packaged_target, \"dist\")\n\n    if config.ui.custom_build and os.path.exists(\n        os.path.join(APP_ROOT, config.ui.custom_build)\n    ):\n        return os.path.join(APP_ROOT, config.ui.custom_build)\n    elif os.path.exists(local_build_dir):\n        return local_build_dir\n    elif os.path.exists(packaged_build_dir):\n        return packaged_build_dir\n    else:\n        raise FileNotFoundError(f\"{local_target} built UI dir not found\")\n\n\nbuild_dir = get_build_dir(\"frontend\", \"frontend\")\ncopilot_build_dir = get_build_dir(os.path.join(\"libs\", \"copilot\"), \"copilot\")\n\napp = FastAPI(lifespan=lifespan)\n\nsio = socketio.AsyncServer(cors_allowed_origins=[], async_mode=\"asgi\")\n\nasgi_app = socketio.ASGIApp(socketio_server=sio, socketio_path=\"\")\n\n# config.run.root_path is only set when started with --root-path. Not on submounts.\nSOCKET_IO_PATH = f\"{config.run.root_path}/ws/socket.io\"\napp.mount(SOCKET_IO_PATH, asgi_app)\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=config.project.allow_origins,\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n\nclass SafariWebSocketsCompatibleGZipMiddleware(GZipMiddleware):\n    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:\n        if scope[\"type\"] != \"http\":\n            return await self.app(scope, receive, send)\n\n        # Prevent gzip compression for HTTP requests to socket.io path due to a bug in Safari\n        if URL(scope=scope).path.startswith(SOCKET_IO_PATH):\n            await self.app(scope, receive, send)\n        else:\n            await super().__call__(scope, receive, send)\n\n\napp.add_middleware(SafariWebSocketsCompatibleGZipMiddleware)\n\n# config.run.root_path is only set when started with --root-path. Not on submounts.\nrouter = APIRouter(prefix=config.run.root_path)\n\n\n@router.get(\"/public/{filename:path}\")\nasync def serve_public_file(\n    filename: str,\n):\n    \"\"\"Serve a file from public dir.\"\"\"\n\n    base_path = Path(public_dir)\n    file_path = (base_path / filename).resolve()\n\n    if not is_path_inside(file_path, base_path):\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    if file_path.is_file():\n        return FileResponse(file_path)\n    else:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n\n@router.get(\"/assets/{filename:path}\")\nasync def serve_asset_file(\n    filename: str,\n):\n    \"\"\"Serve a file from assets dir.\"\"\"\n\n    base_path = Path(os.path.join(build_dir, \"assets\"))\n    file_path = (base_path / filename).resolve()\n\n    if not is_path_inside(file_path, base_path):\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    if file_path.is_file():\n        return FileResponse(file_path)\n    else:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n\n@router.get(\"/copilot/{filename:path}\")\nasync def serve_copilot_file(\n    filename: str,\n):\n    \"\"\"Serve a file from assets dir.\"\"\"\n\n    base_path = Path(copilot_build_dir)\n    file_path = (base_path / filename).resolve()\n\n    if not is_path_inside(file_path, base_path):\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    if file_path.is_file():\n        return FileResponse(file_path)\n    else:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n\n# -------------------------------------------------------------------------------\n#                               SLACK HTTP HANDLER\n# -------------------------------------------------------------------------------\n\nif (\n    os.environ.get(\"SLACK_BOT_TOKEN\")\n    and os.environ.get(\"SLACK_SIGNING_SECRET\")\n    and not os.environ.get(\"SLACK_WEBSOCKET_TOKEN\")\n):\n    from chainlit.slack.app import slack_app_handler\n\n    @router.post(\"/slack/events\")\n    async def slack_endpoint(req: Request):\n        return await slack_app_handler.handle(req)\n\n\n# -------------------------------------------------------------------------------\n#                               TEAMS HANDLER\n# -------------------------------------------------------------------------------\n\nif os.environ.get(\"TEAMS_APP_ID\") and os.environ.get(\"TEAMS_APP_PASSWORD\"):\n    from botbuilder.schema import Activity\n\n    from chainlit.teams.app import adapter, bot\n\n    @router.post(\"/teams/events\")\n    async def teams_endpoint(req: Request):\n        body = await req.json()\n        activity = Activity().deserialize(body)\n        auth_header = req.headers.get(\"Authorization\", \"\")\n        response = await adapter.process_activity(activity, auth_header, bot.on_turn)\n        return response\n\n\n# -------------------------------------------------------------------------------\n#                               HTTP HANDLERS\n# -------------------------------------------------------------------------------\n\n\ndef replace_between_tags(\n    text: str, start_tag: str, end_tag: str, replacement: str\n) -> str:\n    \"\"\"Replace text between two tags in a string.\"\"\"\n\n    pattern = start_tag + \".*?\" + end_tag\n    return re.sub(pattern, start_tag + replacement + end_tag, text, flags=re.DOTALL)\n\n\ndef get_html_template(root_path):\n    \"\"\"\n    Get HTML template for the index view.\n    \"\"\"\n    root_path = root_path.rstrip(\"/\")  # Avoid duplicated / when joining with root path.\n\n    custom_theme = None\n    custom_theme_file_path = Path(public_dir) / \"theme.json\"\n    if (\n        is_path_inside(custom_theme_file_path, Path(public_dir))\n        and custom_theme_file_path.is_file()\n    ):\n        custom_theme = json.loads(custom_theme_file_path.read_text(encoding=\"utf-8\"))\n\n    PLACEHOLDER = \"<!-- TAG INJECTION PLACEHOLDER -->\"\n    JS_PLACEHOLDER = \"<!-- JS INJECTION PLACEHOLDER -->\"\n    CSS_PLACEHOLDER = \"<!-- CSS INJECTION PLACEHOLDER -->\"\n\n    default_url = config.ui.custom_meta_url or \"https://github.com/Chainlit/chainlit\"\n    default_meta_image_url = (\n        \"https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png\"\n    )\n    meta_image_url = config.ui.custom_meta_image_url or default_meta_image_url\n    favicon_path = \"/favicon\"\n\n    tags = f\"\"\"<title>{config.ui.name}</title>\n    <link rel=\"icon\" href=\"{favicon_path}\" />\n    <meta name=\"description\" content=\"{config.ui.description}\">\n    <meta property=\"og:type\" content=\"website\">\n    <meta property=\"og:title\" content=\"{config.ui.name}\">\n    <meta property=\"og:description\" content=\"{config.ui.description}\">\n    <meta property=\"og:image\" content=\"{meta_image_url}\">\n    <meta property=\"og:url\" content=\"{default_url}\">\n    <meta property=\"og:root_path\" content=\"{root_path}\">\"\"\"\n\n    js = f\"\"\"<script>\n{f\"window.theme = {json.dumps(custom_theme.get('variables'))};\" if custom_theme and custom_theme.get(\"variables\") else \"undefined\"}\n{f\"window.transports = {json.dumps(config.project.transports)};\" if config.project.transports else \"undefined\"}\n</script>\"\"\"\n\n    css = None\n    if config.ui.custom_css:\n        css = f\"\"\"<link rel=\"stylesheet\" type=\"text/css\" href=\"{config.ui.custom_css}\" {config.ui.custom_css_attributes}>\"\"\"\n\n    if config.ui.custom_js:\n        js += f\"\"\"<script src=\"{config.ui.custom_js}\" {config.ui.custom_js_attributes}></script>\"\"\"\n\n    font = None\n    if custom_theme and custom_theme.get(\"custom_fonts\"):\n        font = \"\\n\".join(\n            f\"\"\"<link rel=\"stylesheet\" href=\"{font}\">\"\"\"\n            for font in custom_theme.get(\"custom_fonts\")\n        )\n\n    index_html_file_path = os.path.join(build_dir, \"index.html\")\n\n    with open(index_html_file_path, encoding=\"utf-8\") as f:\n        content = f.read()\n        content = content.replace(PLACEHOLDER, tags)\n        if js:\n            content = content.replace(JS_PLACEHOLDER, js)\n        if css:\n            content = content.replace(CSS_PLACEHOLDER, css)\n        if font:\n            content = replace_between_tags(\n                content, \"<!-- FONT START -->\", \"<!-- FONT END -->\", font\n            )\n        content = content.replace('href=\"/', f'href=\"{root_path}/')\n        content = content.replace('src=\"/', f'src=\"{root_path}/')\n        return content\n\n\ndef get_user_facing_url(url: URL):\n    \"\"\"\n    Return the user facing URL for a given URL.\n    Handles deployment with proxies (like cloud run).\n    \"\"\"\n    chainlit_url = os.environ.get(\"CHAINLIT_URL\")\n\n    # No config, we keep the URL as is\n    if not chainlit_url:\n        url = url.replace(query=\"\", fragment=\"\")\n        return url.__str__()\n\n    config_url = URL(chainlit_url).replace(\n        query=\"\",\n        fragment=\"\",\n    )\n    # Remove trailing slash from config URL\n    if config_url.path.endswith(\"/\"):\n        config_url = config_url.replace(path=config_url.path[:-1])\n\n    return config_url.__str__() + url.path\n\n\n@router.get(\"/auth/config\")\nasync def auth(request: Request):\n    return get_configuration()\n\n\ndef _get_response_dict(access_token: str) -> dict:\n    \"\"\"Get the response dictionary for the auth response.\"\"\"\n\n    return {\"success\": True}\n\n\ndef _get_auth_response(access_token: str, redirect_to_callback: bool) -> Response:\n    \"\"\"Get the redirect params for the OAuth callback.\"\"\"\n\n    response_dict = _get_response_dict(access_token)\n\n    if redirect_to_callback:\n        root_path = os.environ.get(\"CHAINLIT_ROOT_PATH\", \"\")\n        root_path = \"\" if root_path == \"/\" else root_path\n        redirect_url = (\n            f\"{root_path}/login/callback?{urllib.parse.urlencode(response_dict)}\"\n        )\n\n        return RedirectResponse(\n            # FIXME: redirect to the right frontend base url to improve the dev environment\n            url=redirect_url,\n            status_code=302,\n        )\n\n    return JSONResponse(response_dict)\n\n\ndef _get_oauth_redirect_error(request: Request, error: str) -> Response:\n    \"\"\"Get the redirect response for an OAuth error.\"\"\"\n    params = urllib.parse.urlencode(\n        {\n            \"error\": error,\n        }\n    )\n    response = RedirectResponse(url=str(request.url_for(\"login\")) + \"?\" + params)\n    return response\n\n\nasync def _authenticate_user(\n    request: Request, user: Optional[User], redirect_to_callback: bool = False\n) -> Response:\n    \"\"\"Authenticate a user and return the response.\"\"\"\n\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"credentialssignin\",\n        )\n\n    # If a data layer is defined, attempt to persist user.\n    if data_layer := get_data_layer():\n        try:\n            await data_layer.create_user(user)\n        except Exception as e:\n            # Catch and log exceptions during user creation.\n            # TODO: Make this catch only specific errors and allow others to propagate.\n            logger.error(f\"Error creating user: {e}\")\n\n    access_token = create_jwt(user)\n\n    response = _get_auth_response(access_token, redirect_to_callback)\n\n    set_auth_cookie(request, response, access_token)\n\n    return response\n\n\n@router.post(\"/login\")\nasync def login(\n    request: Request,\n    response: Response,\n    form_data: OAuth2PasswordRequestForm = Depends(),\n):\n    \"\"\"\n    Login a user using the password auth callback.\n    \"\"\"\n    if not config.code.password_auth_callback:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST, detail=\"No auth_callback defined\"\n        )\n\n    user = await config.code.password_auth_callback(\n        form_data.username, form_data.password\n    )\n\n    return await _authenticate_user(request, user)\n\n\n@router.post(\"/logout\")\nasync def logout(request: Request, response: Response):\n    \"\"\"Logout the user by calling the on_logout callback.\"\"\"\n    clear_auth_cookie(request, response)\n\n    if config.code.on_logout:\n        return await config.code.on_logout(request, response)\n\n    return {\"success\": True}\n\n\n@router.post(\"/auth/jwt\")\nasync def jwt_auth(request: Request):\n    \"\"\"Login a user using a valid jwt.\"\"\"\n    from jwt import InvalidTokenError\n\n    auth_header: Optional[str] = request.headers.get(\"Authorization\")\n    if not auth_header:\n        raise HTTPException(status_code=401, detail=\"Authorization header missing\")\n\n    # Check if it starts with \"Bearer \"\n    try:\n        scheme, token = auth_header.split()\n        if scheme.lower() != \"bearer\":\n            raise HTTPException(\n                status_code=401,\n                detail=\"Invalid authentication scheme. Please use Bearer\",\n            )\n    except ValueError:\n        raise HTTPException(\n            status_code=401, detail=\"Invalid authorization header format\"\n        )\n\n    try:\n        user = decode_jwt(token)\n        return await _authenticate_user(request, user)\n    except InvalidTokenError:\n        raise HTTPException(status_code=401, detail=\"Invalid token\")\n\n\n@router.post(\"/auth/header\")\nasync def header_auth(request: Request):\n    \"\"\"Login a user using the header_auth_callback.\"\"\"\n    if not config.code.header_auth_callback:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"No header_auth_callback defined\",\n        )\n\n    user = await config.code.header_auth_callback(request.headers)\n\n    return await _authenticate_user(request, user)\n\n\n@router.get(\"/auth/oauth/{provider_id}\")\nasync def oauth_login(provider_id: str, request: Request):\n    \"\"\"Redirect the user to the oauth provider login page.\"\"\"\n    if config.code.oauth_callback is None:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"No oauth_callback defined\",\n        )\n\n    provider = get_oauth_provider(provider_id)\n    if not provider:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=f\"Provider {provider_id} not found\",\n        )\n\n    random = random_secret(32)\n\n    params = urllib.parse.urlencode(\n        {\n            \"client_id\": provider.client_id,\n            \"redirect_uri\": f\"{get_user_facing_url(request.url)}/callback\",\n            \"state\": random,\n            **provider.authorize_params,\n        }\n    )\n    response = RedirectResponse(\n        url=f\"{provider.authorize_url}?{params}\",\n    )\n\n    set_oauth_state_cookie(response, random)\n\n    return response\n\n\n@router.get(\"/auth/oauth/{provider_id}/callback\")\nasync def oauth_callback(\n    provider_id: str,\n    request: Request,\n    error: Optional[str] = None,\n    code: Optional[str] = None,\n    state: Optional[str] = None,\n):\n    \"\"\"Handle the oauth callback and login the user.\"\"\"\n\n    if config.code.oauth_callback is None:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"No oauth_callback defined\",\n        )\n\n    provider = get_oauth_provider(provider_id)\n    if not provider:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=f\"Provider {provider_id} not found\",\n        )\n\n    if error:\n        return _get_oauth_redirect_error(request, error)\n\n    if not code or not state:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Missing code or state\",\n        )\n\n    try:\n        validate_oauth_state_cookie(request, state)\n    except Exception as e:\n        logger.exception(\"Unable to validate oauth state: %1\", e)\n\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Unauthorized\",\n        )\n\n    url = get_user_facing_url(request.url)\n    token = await provider.get_token(code, url)\n\n    (raw_user_data, default_user) = await provider.get_user_info(token)\n\n    user = await config.code.oauth_callback(\n        provider_id, token, raw_user_data, default_user\n    )\n\n    response = await _authenticate_user(request, user, redirect_to_callback=True)\n\n    clear_oauth_state_cookie(response)\n\n    return response\n\n\n# specific route for azure ad hybrid flow\n@router.post(\"/auth/oauth/azure-ad-hybrid/callback\")\nasync def oauth_azure_hf_callback(\n    request: Request,\n    error: Optional[str] = None,\n    code: Annotated[Optional[str], Form()] = None,\n    id_token: Annotated[Optional[str], Form()] = None,\n):\n    \"\"\"Handle the azure ad hybrid flow callback and login the user.\"\"\"\n\n    provider_id = \"azure-ad-hybrid\"\n    if config.code.oauth_callback is None:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"No oauth_callback defined\",\n        )\n\n    provider = get_oauth_provider(provider_id)\n    if not provider:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=f\"Provider {provider_id} not found\",\n        )\n\n    if error:\n        return _get_oauth_redirect_error(request, error)\n\n    if not code:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Missing code\",\n        )\n\n    url = get_user_facing_url(request.url)\n    token = await provider.get_token(code, url)\n\n    (raw_user_data, default_user) = await provider.get_user_info(token)\n\n    user = await config.code.oauth_callback(\n        provider_id, token, raw_user_data, default_user, id_token\n    )\n\n    response = await _authenticate_user(request, user, redirect_to_callback=True)\n\n    clear_oauth_state_cookie(response)\n\n    return response\n\n\nGenericUser = Union[User, PersistedUser, None]\nUserParam = Annotated[GenericUser, Depends(get_current_user)]\n\n\n@router.get(\"/user\")\nasync def get_user(current_user: UserParam) -> GenericUser:\n    return current_user\n\n\n_language_pattern = (\n    \"^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?(-[a-zA-Z0-9]{2,8})?(-x-[a-zA-Z0-9]{1,8})?$\"\n)\n\n\n@router.post(\"/set-session-cookie\")\nasync def set_session_cookie(request: Request, response: Response):\n    body = await request.json()\n    session_id = body.get(\"session_id\")\n\n    is_local = request.client and request.client.host in [\"127.0.0.1\", \"localhost\"]\n\n    response.set_cookie(\n        key=\"X-Chainlit-Session-id\",\n        value=session_id,\n        path=\"/\",\n        httponly=True,\n        secure=not is_local,\n        samesite=\"lax\" if is_local else \"none\",\n    )\n\n    return {\"message\": \"Session cookie set\"}\n\n\n@router.get(\"/project/translations\")\nasync def project_translations(\n    language: str = Query(\n        default=\"en-US\", description=\"Language code\", pattern=_language_pattern\n    ),\n):\n    \"\"\"Return project translations.\"\"\"\n\n    # Use configured language if set, otherwise use the language from query\n    effective_language = config.ui.language or language\n\n    # Load translation based on the effective language\n    translation = config.load_translation(effective_language)\n\n    return JSONResponse(\n        content={\n            \"translation\": translation,\n        }\n    )\n\n\n@router.get(\"/project/settings\")\nasync def project_settings(\n    current_user: UserParam,\n    language: str = Query(\n        default=\"en-US\", description=\"Language code\", pattern=_language_pattern\n    ),\n    chat_profile: Optional[str] = Query(\n        default=None, description=\"Current chat profile name\"\n    ),\n):\n    \"\"\"Return project settings. This is called by the UI before the establishing the websocket connection.\"\"\"\n\n    # Use configured language if set, otherwise use the language from query\n    effective_language = config.ui.language or language\n\n    # Load the markdown file based on the provided language\n    markdown = get_markdown_str(config.root, effective_language)\n\n    chat_profiles = []\n    profiles: list[dict] = []\n    if config.code.set_chat_profiles:\n        chat_profiles = await config.code.set_chat_profiles(\n            current_user, effective_language\n        )\n        if chat_profiles:\n            for p in chat_profiles:\n                d = p.to_dict()\n                d.pop(\"config_overrides\", None)\n                profiles.append(d)\n\n    starters = []\n    if config.code.set_starters:\n        s = await config.code.set_starters(current_user, effective_language)\n        if s:\n            starters = [it.to_dict() for it in s]\n\n    starter_categories = []\n    if config.code.set_starter_categories:\n        sc = await config.code.set_starter_categories(current_user, effective_language)\n        if sc:\n            starter_categories = [it.to_dict() for it in sc]\n\n    data_layer = get_data_layer()\n    debug_url = (\n        await data_layer.build_debug_url() if data_layer and config.run.debug else None\n    )\n\n    cfg = config\n    if chat_profile and chat_profiles:\n        current_profile = next(\n            (p for p in chat_profiles if p.name == chat_profile), None\n        )\n        if current_profile and getattr(current_profile, \"config_overrides\", None):\n            cfg = config.with_overrides(current_profile.config_overrides)\n\n    return JSONResponse(\n        content={\n            \"ui\": cfg.ui.model_dump(),\n            \"features\": cfg.features.model_dump(),\n            \"userEnv\": cfg.project.user_env,\n            \"maskUserEnv\": cfg.project.mask_user_env,\n            \"dataPersistence\": data_layer is not None,\n            \"threadResumable\": bool(config.code.on_chat_resume),\n            # Expose whether shared threads feature is enabled (flag + app callback)\n            \"threadSharing\": bool(\n                getattr(cfg.features, \"allow_thread_sharing\", False)\n                and getattr(config.code, \"on_shared_thread_view\", None)\n            ),\n            \"markdown\": markdown,\n            \"chatProfiles\": profiles,\n            \"starters\": starters,\n            \"starterCategories\": starter_categories,\n            \"debugUrl\": debug_url,\n        }\n    )\n\n\n@router.put(\"/feedback\")\nasync def update_feedback(\n    request: Request,\n    update: UpdateFeedbackRequest,\n    current_user: UserParam,\n):\n    \"\"\"Update the human feedback for a particular message.\"\"\"\n    data_layer = get_data_layer()\n    if not data_layer:\n        raise HTTPException(status_code=500, detail=\"Data persistence is not enabled\")\n\n    try:\n        feedback_id = await data_layer.upsert_feedback(feedback=update.feedback)\n\n        if config.code.on_feedback:\n            try:\n                from chainlit.context import init_ws_context\n                from chainlit.session import WebsocketSession\n\n                session = WebsocketSession.get_by_id(update.sessionId)\n                init_ws_context(session)\n\n                await config.code.on_feedback(update.feedback)\n            except Exception as callback_error:\n                logger.error(\n                    f\"Error in user-provided on_feedback callback: {callback_error}\"\n                )\n                # Optionally, you could continue without raising an exception to avoid disrupting the endpoint.\n    except Exception as e:\n        raise HTTPException(detail=str(e), status_code=500) from e\n\n    return JSONResponse(content={\"success\": True, \"feedbackId\": feedback_id})\n\n\n@router.delete(\"/feedback\")\nasync def delete_feedback(\n    request: Request,\n    payload: DeleteFeedbackRequest,\n    current_user: UserParam,\n):\n    \"\"\"Delete a feedback.\"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    feedback_id = payload.feedbackId\n\n    await data_layer.delete_feedback(feedback_id)\n    return JSONResponse(content={\"success\": True})\n\n\n@router.post(\"/project/threads\")\nasync def get_user_threads(\n    request: Request,\n    payload: GetThreadsRequest,\n    current_user: UserParam,\n):\n    \"\"\"Get the threads page by page.\"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    if not isinstance(current_user, PersistedUser):\n        persisted_user = await data_layer.get_user(identifier=current_user.identifier)\n        if not persisted_user:\n            raise HTTPException(status_code=404, detail=\"User not found\")\n        payload.filter.userId = persisted_user.id\n    else:\n        payload.filter.userId = current_user.id\n\n    res = await data_layer.list_threads(payload.pagination, payload.filter)\n    return JSONResponse(content=res.to_dict())\n\n\n@router.get(\"/project/thread/{thread_id}\")\nasync def get_thread(\n    request: Request,\n    thread_id: str,\n    current_user: UserParam,\n):\n    \"\"\"Get a specific thread.\"\"\"\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    await is_thread_author(current_user.identifier, thread_id)\n\n    res = await data_layer.get_thread(thread_id)\n    return JSONResponse(content=res)\n\n\n@router.get(\"/project/share/{thread_id}\")\nasync def get_shared_thread(\n    request: Request,\n    thread_id: str,\n    current_user: UserParam,\n):\n    \"\"\"Get a shared thread (read-only for everyone).\n\n    This endpoint is separate from the resume endpoint and does not require the caller\n    to be the author of the thread. It only returns the thread if its metadata\n    contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence.\n    \"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    # No auth required: allow anonymous access to shared threads\n    thread = await data_layer.get_thread(thread_id)\n\n    if not thread:\n        raise HTTPException(status_code=404, detail=\"Thread not found\")\n    # Extract and normalize metadata (may be dict, strified JSON, or None)\n    metadata = (thread.get(\"metadata\") if isinstance(thread, dict) else {}) or {}\n    if isinstance(metadata, str):\n        try:\n            metadata = json.loads(metadata)\n        except Exception:\n            metadata = {}\n    if not isinstance(metadata, dict):\n        metadata = {}\n\n    user_can_view = False\n    if getattr(config.code, \"on_shared_thread_view\", None):\n        try:\n            user_can_view = await config.code.on_shared_thread_view(\n                thread, current_user\n            )\n        except Exception:\n            user_can_view = False\n\n    is_shared = bool(metadata.get(\"is_shared\"))\n\n    # Proceed only raise an error if both conditions are False.\n    if (not user_can_view) and (not is_shared):\n        raise HTTPException(status_code=404, detail=\"Thread not found\")\n\n    metadata.pop(\"chat_profile\", None)\n    metadata.pop(\"chat_settings\", None)\n    metadata.pop(\"env\", None)\n    thread[\"metadata\"] = metadata\n    return JSONResponse(content=thread)\n\n\n@router.get(\"/project/thread/{thread_id}/element/{element_id}\")\nasync def get_thread_element(\n    request: Request,\n    thread_id: str,\n    element_id: str,\n    current_user: UserParam,\n):\n    \"\"\"Get a specific thread element.\"\"\"\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    await is_thread_author(current_user.identifier, thread_id)\n\n    res = await data_layer.get_element(thread_id, element_id)\n    return JSONResponse(content=res)\n\n\n@router.put(\"/project/element\")\nasync def update_thread_element(\n    payload: ElementRequest,\n    current_user: UserParam,\n):\n    \"\"\"Update a specific thread element.\"\"\"\n\n    from chainlit.context import init_ws_context\n    from chainlit.element import ElementDict\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(payload.sessionId)\n    context = init_ws_context(session)\n\n    element_dict = cast(ElementDict, payload.element)\n\n    if element_dict[\"type\"] != \"custom\":\n        return {\"success\": False}\n\n    element = _sanitize_custom_element(element_dict)\n\n    if current_user:\n        if (\n            not context.session.user\n            or context.session.user.identifier != current_user.identifier\n        ):\n            raise HTTPException(\n                status_code=401,\n                detail=\"You are not authorized to update elements for this session\",\n            )\n\n    await element.update()\n\n    return {\"success\": True}\n\n\n@router.delete(\"/project/element\")\nasync def delete_thread_element(\n    payload: ElementRequest,\n    current_user: UserParam,\n):\n    \"\"\"Delete a specific thread element.\"\"\"\n\n    from chainlit.context import init_ws_context\n    from chainlit.element import ElementDict\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(payload.sessionId)\n    context = init_ws_context(session)\n\n    element_dict = cast(ElementDict, payload.element)\n\n    if element_dict[\"type\"] != \"custom\":\n        return {\"success\": False}\n\n    element = _sanitize_custom_element(element_dict)\n\n    if current_user:\n        if (\n            not context.session.user\n            or context.session.user.identifier != current_user.identifier\n        ):\n            raise HTTPException(\n                status_code=401,\n                detail=\"You are not authorized to remove elements for this session\",\n            )\n\n    await element.remove()\n\n    return {\"success\": True}\n\n\ndef _sanitize_custom_element(element_dict: \"ElementDict\") -> \"CustomElement\":\n    from chainlit.element import CustomElement\n\n    return CustomElement(\n        id=element_dict[\"id\"],\n        for_id=element_dict.get(\"forId\") or \"\",\n        thread_id=element_dict.get(\"threadId\") or \"\",\n        name=element_dict[\"name\"],\n        props=element_dict.get(\"props\") or {},\n        display=element_dict[\"display\"],\n    )\n\n\n@router.put(\"/project/thread\")\nasync def rename_thread(\n    request: Request,\n    payload: UpdateThreadRequest,\n    current_user: UserParam,\n):\n    \"\"\"Rename a thread.\"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    thread_id = payload.threadId\n\n    await is_thread_author(current_user.identifier, thread_id)\n\n    await data_layer.update_thread(thread_id, name=payload.name)\n\n    return JSONResponse(content={\"success\": True})\n\n\n@router.put(\"/project/thread/share\")\nasync def share_thread(\n    request: Request,\n    payload: ShareThreadRequest,\n    current_user: UserParam,\n):\n    \"\"\"Share or un-share a thread (author only).\"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    thread_id = payload.threadId\n\n    await is_thread_author(current_user.identifier, thread_id)\n\n    # Fetch current thread and metadata, then toggle is_shared\n    thread = await data_layer.get_thread(thread_id=thread_id)\n    metadata = (thread.get(\"metadata\") if thread else {}) or {}\n    if isinstance(metadata, str):\n        try:\n            metadata = json.loads(metadata)\n        except Exception:\n            metadata = {}\n    if not isinstance(metadata, dict):\n        metadata = {}\n\n    metadata = dict(metadata)\n    is_shared = bool(payload.isShared)\n    metadata[\"is_shared\"] = is_shared\n    if is_shared:\n        metadata[\"shared_at\"] = utc_now()\n    else:\n        metadata.pop(\"shared_at\", None)\n    try:\n        await data_layer.update_thread(thread_id=thread_id, metadata=metadata)\n        logger.debug(\n            \"[share_thread] updated metadata for thread=%s to %s\",\n            thread_id,\n            metadata,\n        )\n    except Exception as e:\n        logger.exception(\"[share_thread] update_thread failed: %s\", e)\n        raise\n\n    return JSONResponse(content={\"success\": True})\n\n\n@router.delete(\"/project/thread\")\nasync def delete_thread(\n    request: Request,\n    payload: DeleteThreadRequest,\n    current_user: UserParam,\n):\n    \"\"\"Delete a thread.\"\"\"\n\n    data_layer = get_data_layer()\n\n    if not data_layer:\n        raise HTTPException(status_code=400, detail=\"Data persistence is not enabled\")\n\n    if not current_user:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    thread_id = payload.threadId\n\n    await is_thread_author(current_user.identifier, thread_id)\n\n    await data_layer.delete_thread(thread_id)\n    return JSONResponse(content={\"success\": True})\n\n\n@router.post(\"/project/action\")\nasync def call_action(\n    payload: CallActionRequest,\n    current_user: UserParam,\n):\n    \"\"\"Run an action.\"\"\"\n\n    from chainlit.action import Action\n    from chainlit.context import init_ws_context\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(payload.sessionId)\n    context = init_ws_context(session)\n    config: ChainlitConfig = session.get_config()\n\n    action = Action(**payload.action)\n\n    if current_user:\n        if (\n            not context.session.user\n            or context.session.user.identifier != current_user.identifier\n        ):\n            raise HTTPException(\n                status_code=401,\n                detail=\"You are not authorized to upload files for this session\",\n            )\n\n    callback = config.code.action_callbacks.get(action.name)\n    if callback:\n        if not context.session.has_first_interaction:\n            context.session.has_first_interaction = True\n            asyncio.create_task(context.emitter.init_thread(action.name))\n\n        response = await callback(action)\n    else:\n        raise HTTPException(\n            status_code=404,\n            detail=f\"No callback found for action {action.name}\",\n        )\n\n    return JSONResponse(content={\"success\": True, \"response\": response})\n\n\n@router.post(\"/mcp\")\nasync def connect_mcp(\n    payload: ConnectMCPRequest,\n    current_user: UserParam,\n):\n    from mcp import ClientSession\n    from mcp.client.sse import sse_client\n    from mcp.client.stdio import (\n        StdioServerParameters,\n        get_default_environment,\n        stdio_client,\n    )\n    from mcp.client.streamable_http import streamablehttp_client\n\n    from chainlit.context import init_ws_context\n    from chainlit.mcp import (\n        HttpMcpConnection,\n        McpConnection,\n        SseMcpConnection,\n        StdioMcpConnection,\n        validate_mcp_command,\n    )\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(payload.sessionId)\n    context = init_ws_context(session)\n    config: ChainlitConfig = session.get_config()\n\n    if current_user:\n        if (\n            not context.session.user\n            or context.session.user.identifier != current_user.identifier\n        ):\n            raise HTTPException(\n                status_code=401,\n            )\n\n    mcp_enabled = config.features.mcp.enabled\n    if mcp_enabled:\n        if payload.name in session.mcp_sessions:\n            old_client_session, old_exit_stack = session.mcp_sessions[payload.name]\n            if on_mcp_disconnect := config.code.on_mcp_disconnect:\n                await on_mcp_disconnect(payload.name, old_client_session)\n            try:\n                await old_exit_stack.aclose()\n            except Exception:\n                pass\n\n        try:\n            exit_stack = AsyncExitStack()\n            mcp_connection: McpConnection\n\n            if payload.clientType == \"sse\":\n                if not config.features.mcp.sse.enabled:\n                    raise HTTPException(\n                        status_code=400,\n                        detail=\"SSE MCP is not enabled\",\n                    )\n\n                mcp_connection = SseMcpConnection(\n                    url=payload.url,\n                    name=payload.name,\n                    headers=getattr(payload, \"headers\", None),\n                )\n\n                transport = await exit_stack.enter_async_context(\n                    sse_client(\n                        url=mcp_connection.url,\n                        headers=mcp_connection.headers,\n                    )\n                )\n            elif payload.clientType == \"stdio\":\n                if not config.features.mcp.stdio.enabled:\n                    raise HTTPException(\n                        status_code=400,\n                        detail=\"Stdio MCP is not enabled\",\n                    )\n\n                env_from_cmd, command, args = validate_mcp_command(payload.fullCommand)\n                mcp_connection = StdioMcpConnection(\n                    command=command, args=args, name=payload.name\n                )\n\n                env = get_default_environment()\n                env.update(env_from_cmd)\n                # Create the server parameters\n                server_params = StdioServerParameters(\n                    command=command, args=args, env=env\n                )\n\n                transport = await exit_stack.enter_async_context(\n                    stdio_client(server_params)\n                )\n\n            elif payload.clientType == \"streamable-http\":\n                if not config.features.mcp.streamable_http.enabled:\n                    raise HTTPException(\n                        status_code=400,\n                        detail=\"HTTP MCP is not enabled\",\n                    )\n                mcp_connection = HttpMcpConnection(\n                    url=payload.url,\n                    name=payload.name,\n                    headers=getattr(payload, \"headers\", None),\n                )\n                transport = await exit_stack.enter_async_context(\n                    streamablehttp_client(\n                        url=mcp_connection.url,\n                        headers=mcp_connection.headers,\n                    )\n                )\n\n            # The transport can return (read, write) for stdio, sse\n            # Or (read, write, get_session_id) for streamable-http\n            # We are only interested in the read and write streams here.\n            read, write = transport[:2]\n\n            mcp_session: ClientSession = await exit_stack.enter_async_context(\n                ClientSession(\n                    read_stream=read, write_stream=write, sampling_callback=None\n                )\n            )\n\n            # Initialize the session\n            await mcp_session.initialize()\n\n            # Store the session\n            session.mcp_sessions[mcp_connection.name] = (mcp_session, exit_stack)\n\n            # Call the callback\n            if config.code.on_mcp_connect:\n                await config.code.on_mcp_connect(mcp_connection, mcp_session)\n\n        except Exception as e:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Could not connect to the MCP: {e!s}\",\n            )\n    else:\n        raise HTTPException(\n            status_code=400,\n            detail=\"This app does not support MCP.\",\n        )\n\n    tool_list = await mcp_session.list_tools()\n\n    return JSONResponse(\n        content={\n            \"success\": True,\n            \"mcp\": {\n                \"name\": payload.name,\n                \"tools\": [{\"name\": t.name} for t in tool_list.tools],\n                \"clientType\": payload.clientType,\n                \"command\": payload.fullCommand\n                if payload.clientType == \"stdio\"\n                else None,\n                \"url\": getattr(payload, \"url\", None)\n                if payload.clientType in [\"sse\", \"streamable-http\"]\n                else None,\n                # Include optional headers for SSE and streamable-http connections\n                \"headers\": getattr(payload, \"headers\", None)\n                if payload.clientType in [\"sse\", \"streamable-http\"]\n                else None,\n            },\n        }\n    )\n\n\n@router.delete(\"/mcp\")\nasync def disconnect_mcp(\n    payload: DisconnectMCPRequest,\n    current_user: UserParam,\n):\n    from chainlit.context import init_ws_context\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(payload.sessionId)\n    context = init_ws_context(session)\n\n    if current_user:\n        if (\n            not context.session.user\n            or context.session.user.identifier != current_user.identifier\n        ):\n            raise HTTPException(\n                status_code=401,\n            )\n\n    callback = config.code.on_mcp_disconnect\n    if payload.name in session.mcp_sessions:\n        try:\n            client_session, exit_stack = session.mcp_sessions[payload.name]\n            if callback:\n                await callback(payload.name, client_session)\n\n            try:\n                await exit_stack.aclose()\n            except Exception:\n                pass\n            del session.mcp_sessions[payload.name]\n\n        except Exception as e:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Could not disconnect to the MCP: {e!s}\",\n            )\n\n    return JSONResponse(content={\"success\": True})\n\n\n@router.post(\"/project/file\")\nasync def upload_file(\n    current_user: UserParam,\n    session_id: str,\n    file: UploadFile,\n    ask_parent_id: Optional[str] = None,\n):\n    \"\"\"Upload a file to the session files directory.\"\"\"\n\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(session_id)\n\n    if not session:\n        raise HTTPException(\n            status_code=404,\n            detail=\"Session not found\",\n        )\n\n    if current_user:\n        if not session.user or session.user.identifier != current_user.identifier:\n            raise HTTPException(\n                status_code=401,\n                detail=\"You are not authorized to upload files for this session\",\n            )\n\n    session.files_dir.mkdir(exist_ok=True)\n\n    try:\n        content = await file.read()\n\n        assert file.filename, \"No filename for uploaded file\"\n        assert file.content_type, \"No content type for uploaded file\"\n\n        spec: AskFileSpec = session.files_spec.get(ask_parent_id, None)\n        if not spec and ask_parent_id:\n            raise HTTPException(\n                status_code=404,\n                detail=\"Parent message not found\",\n            )\n\n        try:\n            validate_file_upload(file, spec=spec)\n        except ValueError as e:\n            raise HTTPException(status_code=400, detail=str(e))\n\n        file_response = await session.persist_file(\n            name=file.filename, content=content, mime=file.content_type\n        )\n\n        return JSONResponse(content=file_response)\n    finally:\n        await file.close()\n\n\ndef validate_file_upload(file: UploadFile, spec: Optional[AskFileSpec] = None):\n    \"\"\"Validate the file upload as configured in config.features.spontaneous_file_upload or by AskFileSpec\n    for a specific message.\n\n    Args:\n        file (UploadFile): The file to validate.\n        spec (AskFileSpec): The file spec to validate against if any.\n    Raises:\n        ValueError: If the file is not allowed.\n    \"\"\"\n    if not spec and config.features.spontaneous_file_upload is None:\n        \"\"\"Default for a missing config is to allow the fileupload without any restrictions\"\"\"\n        return\n\n    if not spec and not config.features.spontaneous_file_upload.enabled:\n        raise ValueError(\"File upload is not enabled\")\n\n    validate_file_mime_type(file, spec)\n    validate_file_size(file, spec)\n\n\ndef validate_file_mime_type(file: UploadFile, spec: Optional[AskFileSpec]):\n    \"\"\"Validate the file mime type as configured in config.features.spontaneous_file_upload.\n    Args:\n        file (UploadFile): The file to validate.\n    Raises:\n        ValueError: If the file type is not allowed.\n    \"\"\"\n\n    if not spec and (\n        config.features.spontaneous_file_upload is None\n        or config.features.spontaneous_file_upload.accept is None\n    ):\n        \"Accept is not configured, allowing all file types\"\n        return\n\n    accept = config.features.spontaneous_file_upload.accept if not spec else spec.accept\n\n    assert isinstance(accept, List) or isinstance(accept, dict), (\n        \"Invalid configuration for spontaneous_file_upload, accept must be a list or a dict\"\n    )\n\n    if isinstance(accept, List):\n        for pattern in accept:\n            if fnmatch.fnmatch(str(file.content_type), pattern):\n                return\n    elif isinstance(accept, dict):\n        for pattern, extensions in accept.items():\n            if fnmatch.fnmatch(str(file.content_type), pattern):\n                if len(extensions) == 0:\n                    return\n                for extension in extensions:\n                    if file.filename is not None and file.filename.lower().endswith(\n                        extension.lower()\n                    ):\n                        return\n    raise ValueError(\"File type not allowed\")\n\n\ndef validate_file_size(file: UploadFile, spec: Optional[AskFileSpec]):\n    \"\"\"Validate the file size as configured in config.features.spontaneous_file_upload.\n    Args:\n        file (UploadFile): The file to validate.\n    Raises:\n        ValueError: If the file size is too large.\n    \"\"\"\n    if not spec and (\n        config.features.spontaneous_file_upload is None\n        or config.features.spontaneous_file_upload.max_size_mb is None\n    ):\n        return\n\n    max_size_mb = (\n        config.features.spontaneous_file_upload.max_size_mb\n        if not spec\n        else spec.max_size_mb\n    )\n    if file.size is not None and file.size > max_size_mb * 1024 * 1024:\n        raise ValueError(\"File size too large\")\n\n\n@router.get(\"/project/file/{file_id}\")\nasync def get_file(\n    file_id: str,\n    session_id: str,\n    current_user: UserParam,\n):\n    \"\"\"Get a file from the session files directory.\"\"\"\n    from chainlit.session import WebsocketSession\n\n    session = WebsocketSession.get_by_id(session_id) if session_id else None\n\n    if not session:\n        raise HTTPException(\n            status_code=401,\n            detail=\"Unauthorized\",\n        )\n\n    if current_user:\n        if not session.user or session.user.identifier != current_user.identifier:\n            raise HTTPException(\n                status_code=401,\n                detail=\"You are not authorized to download files from this session\",\n            )\n\n    if file_id in session.files:\n        file = session.files[file_id]\n        return FileResponse(file[\"path\"], media_type=file[\"type\"])\n    else:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n\n@router.get(\"/favicon\")\nasync def get_favicon():\n    \"\"\"Get the favicon for the UI.\"\"\"\n    custom_favicon_path = os.path.join(APP_ROOT, \"public\", \"favicon.*\")\n    files = glob.glob(custom_favicon_path)\n\n    if files:\n        favicon_path = files[0]\n    else:\n        favicon_path = os.path.join(build_dir, \"favicon.svg\")\n\n    media_type, _ = mimetypes.guess_type(favicon_path)\n\n    return FileResponse(favicon_path, media_type=media_type)\n\n\n@router.get(\"/logo\")\nasync def get_logo(theme: Optional[Theme] = Query(Theme.light)):\n    \"\"\"Get the default logo for the UI.\"\"\"\n    theme_value = theme.value if theme else Theme.light.value\n    logo_path = None\n\n    for path in [\n        os.path.join(APP_ROOT, \"public\", f\"logo_{theme_value}.*\"),\n        os.path.join(build_dir, \"assets\", f\"logo_{theme_value}*.*\"),\n    ]:\n        files = glob.glob(path)\n\n        if files:\n            logo_path = files[0]\n            break\n\n    if not logo_path:\n        logo_path = os.path.join(\n            os.path.dirname(__file__),\n            \"frontend\",\n            \"dist\",\n            f\"logo_{theme_value}.svg\",\n        )\n        logger.info(\"Missing custom logo. Falling back to default logo.\")\n\n    media_type, _ = mimetypes.guess_type(logo_path)\n\n    return FileResponse(logo_path, media_type=media_type)\n\n\n@router.get(\"/avatars/{avatar_id:str}\")\nasync def get_avatar(avatar_id: str):\n    \"\"\"Get the avatar for the user based on the avatar_id.\"\"\"\n    if not re.match(r\"^[a-zA-Z0-9_ .-]+$\", avatar_id):\n        raise HTTPException(status_code=400, detail=\"Invalid avatar_id\")\n\n    if avatar_id == \"default\":\n        avatar_id = config.ui.name\n\n    avatar_id = avatar_id.strip().lower().replace(\" \", \"_\").replace(\".\", \"_\")\n\n    base_path = Path(APP_ROOT) / \"public\" / \"avatars\"\n    avatar_pattern = f\"{avatar_id}.*\"\n\n    matching_files = base_path.glob(avatar_pattern)\n\n    if avatar_path := next(matching_files, None):\n        if not is_path_inside(avatar_path, base_path):\n            raise HTTPException(status_code=400, detail=\"Invalid filename\")\n        media_type, _ = mimetypes.guess_type(str(avatar_path))\n\n        return FileResponse(avatar_path, media_type=media_type)\n\n    return await get_favicon()\n\n\n@router.head(\"/\")\ndef status_check():\n    \"\"\"Check if the site is operational.\"\"\"\n    return {\"message\": \"Site is operational\"}\n\n\n@router.get(\"/health\")\ndef health_check():\n    \"\"\"Health check endpoint for container orchestration and monitoring.\"\"\"\n    return {\"status\": \"ok\"}\n\n\n@router.get(\"/{full_path:path}\")\nasync def serve(request: Request):\n    \"\"\"Serve the UI files.\"\"\"\n    root_path = os.getenv(\"CHAINLIT_PARENT_ROOT_PATH\", \"\") + os.getenv(\n        \"CHAINLIT_ROOT_PATH\", \"\"\n    )\n    html_template = get_html_template(root_path)\n    response = HTMLResponse(content=html_template, status_code=200)\n\n    return response\n\n\napp.include_router(router)\n\nimport chainlit.socket  # noqa\n"
  },
  {
    "path": "backend/chainlit/session.py",
    "content": "import asyncio\nimport json\nimport mimetypes\nimport re\nimport shutil\nimport uuid\nfrom contextlib import AsyncExitStack\nfrom typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Literal, Optional, Union\n\nimport aiofiles\n\nfrom chainlit.logger import logger\nfrom chainlit.types import AskFileSpec, FileReference\n\nif TYPE_CHECKING:\n    from mcp import ClientSession\n\n    from chainlit.config import ChainlitConfig\n    from chainlit.types import FileDict\n    from chainlit.user import PersistedUser, User\n\nClientType = Literal[\"webapp\", \"copilot\", \"teams\", \"slack\", \"discord\"]\n\n\nclass JSONEncoderIgnoreNonSerializable(json.JSONEncoder):\n    def default(self, o):\n        try:\n            return super().default(o)\n        except TypeError:\n            return None\n\n\ndef clean_metadata(metadata: Dict, max_size: int = 1048576):\n    cleaned_metadata = json.loads(\n        json.dumps(metadata, cls=JSONEncoderIgnoreNonSerializable, ensure_ascii=False)\n    )\n\n    metadata_size = len(json.dumps(cleaned_metadata).encode(\"utf-8\"))\n    if metadata_size > max_size:\n        # Redact the metadata if it exceeds the maximum size\n        cleaned_metadata = {\n            \"message\": f\"Metadata size exceeds the limit of {max_size} bytes. Redacted.\"\n        }\n\n    return cleaned_metadata\n\n\nclass BaseSession:\n    \"\"\"Base object.\"\"\"\n\n    thread_id_to_resume: Optional[str] = None\n    client_type: ClientType\n    current_task: Optional[asyncio.Task] = None\n\n    def __init__(\n        self,\n        # Id of the session\n        id: str,\n        client_type: ClientType,\n        # Thread id\n        thread_id: Optional[str],\n        # Logged-in user information\n        user: Optional[Union[\"User\", \"PersistedUser\"]],\n        # Logged-in user token\n        token: Optional[str],\n        # User specific environment variables. Empty if no user environment variables are required.\n        user_env: Optional[Dict[str, str]],\n        # WSGI environment variables for the connection request\n        environ: Optional[dict[str, Any]] = None,\n        # Chat profile selected before the session was created\n        chat_profile: Optional[str] = None,\n    ):\n        if thread_id:\n            self.thread_id_to_resume = thread_id\n        self.thread_id = thread_id or str(uuid.uuid4())\n        self.user = user\n        self.client_type = client_type\n        self.token = token\n        self.has_first_interaction = False\n        self.user_env = user_env or {}\n        self.environ = environ or {}\n        self.chat_profile = chat_profile\n\n        self.files: Dict[str, FileDict] = {}\n        self.files_spec: Dict[str, AskFileSpec] = {}\n\n        self.id = id\n\n        self.chat_settings: Dict[str, Any] = {}\n\n    @property\n    def files_dir(self):\n        from chainlit.config import FILES_DIRECTORY\n\n        return FILES_DIRECTORY / self.id\n\n    async def persist_file(\n        self,\n        name: str,\n        mime: str,\n        path: Optional[str] = None,\n        content: Optional[Union[bytes, str]] = None,\n    ) -> FileReference:\n        if not path and not content:\n            raise ValueError(\n                \"Either path or content must be provided to persist a file\"\n            )\n\n        self.files_dir.mkdir(exist_ok=True)\n\n        file_id = str(uuid.uuid4())\n\n        file_path = self.files_dir / file_id\n\n        file_extension = mimetypes.guess_extension(mime)\n\n        if file_extension:\n            file_path = file_path.with_suffix(file_extension)\n\n        if path:\n            # Copy the file from the given path\n            async with (\n                aiofiles.open(path, \"rb\") as src,\n                aiofiles.open(file_path, \"wb\") as dst,\n            ):\n                await dst.write(await src.read())\n        elif content:\n            # Write the provided content to the file\n            async with aiofiles.open(file_path, \"wb\") as buffer:\n                if isinstance(content, str):\n                    content = content.encode(\"utf-8\")\n                await buffer.write(content)\n\n        # Get the file size\n        file_size = file_path.stat().st_size\n        # Store the file content in memory\n        self.files[file_id] = {\n            \"id\": file_id,\n            \"path\": file_path,\n            \"name\": name,\n            \"type\": mime,\n            \"size\": file_size,\n        }\n\n        return {\"id\": file_id}\n\n    def to_persistable(self) -> Dict:\n        from chainlit.config import config\n        from chainlit.user_session import user_sessions\n\n        user_session = user_sessions.get(self.id) or {}  # type: Dict\n        user_session[\"chat_settings\"] = self.chat_settings\n        user_session[\"chat_profile\"] = self.chat_profile\n        user_session[\"client_type\"] = self.client_type\n\n        # Check config setting for whether to persist user environment variables\n        user_session_copy = user_session.copy()\n        if not config.project.persist_user_env:\n            # Remove user environment variables (API keys) before persisting to database\n            user_session_copy[\"env\"] = {}\n\n        metadata = clean_metadata(user_session_copy)\n        return metadata\n\n\nclass HTTPSession(BaseSession):\n    \"\"\"Internal HTTP session object. Used to consume Chainlit through API (no websocket).\"\"\"\n\n    def __init__(\n        self,\n        # Id of the session\n        id: str,\n        client_type: ClientType,\n        # Thread id\n        thread_id: Optional[str] = None,\n        # Logged-in user information\n        user: Optional[Union[\"User\", \"PersistedUser\"]] = None,\n        # Logged-in user token\n        token: Optional[str] = None,\n        user_env: Optional[Dict[str, str]] = None,\n        # WSGI environment variables for the connection request\n        environ: Optional[dict[str, Any]] = None,\n    ):\n        super().__init__(\n            id=id,\n            thread_id=thread_id,\n            user=user,\n            token=token,\n            client_type=client_type,\n            user_env=user_env,\n            environ=environ,\n        )\n\n    async def delete(self):\n        \"\"\"Delete the session.\"\"\"\n        if self.files_dir.is_dir():\n            shutil.rmtree(self.files_dir)\n\n\nThreadQueue = Deque[tuple[Callable, object, tuple, Dict]]\n\n\nclass WebsocketSession(BaseSession):\n    \"\"\"Internal web socket session object.\n\n    A socket id is an ephemeral id that can't be used as a session id\n    (as it is for instance regenerated after each reconnection).\n\n    The Session object store an internal mapping between socket id and\n    a server generated session id, allowing to persists session\n    between socket reconnection but also retrieving a session by\n    socket id for convenience.\n    \"\"\"\n\n    to_clear: bool = False\n\n    mcp_sessions: dict[str, tuple[\"ClientSession\", AsyncExitStack]]\n\n    def __init__(\n        self,\n        # Id from the session cookie\n        id: str,\n        # Associated socket id\n        socket_id: str,\n        # Function to emit to the client\n        emit: Callable[[str, Any], None],\n        # Function to emit to the client and wait for a response\n        emit_call: Callable[[Literal[\"ask\", \"call_fn\"], Any, Optional[int]], Any],\n        # User specific environment variables. Empty if no user environment variables are required.\n        user_env: Dict[str, str],\n        client_type: ClientType,\n        # WSGI environment variables for the connection request\n        environ: Optional[dict[str, Any]] = None,\n        # Thread id\n        thread_id: Optional[str] = None,\n        # Logged-in user information\n        user: Optional[Union[\"User\", \"PersistedUser\"]] = None,\n        # Logged-in user token\n        token: Optional[str] = None,\n        # Chat profile selected before the session was created\n        chat_profile: Optional[str] = None,\n    ):\n        super().__init__(\n            id=id,\n            thread_id=thread_id,\n            user=user,\n            token=token,\n            user_env=user_env,\n            client_type=client_type,\n            chat_profile=chat_profile,\n            environ=environ,\n        )\n\n        self.socket_id = socket_id\n        self.emit_call = emit_call\n        self.emit = emit\n\n        self.restored = False\n\n        self.thread_queues: Dict[str, ThreadQueue] = {}\n        self.mcp_sessions = {}\n\n        match = (\n            re.match(\n                r\"^\\s*([a-zA-Z0-9-]+)\", environ.get(\"HTTP_ACCEPT_LANGUAGE\", \"en-US\")\n            )\n            if environ\n            else None\n        )\n        self.language = match.group(1) if match else \"en-US\"\n\n        self.config: ChainlitConfig = self.get_config()\n\n        ws_sessions_id[self.id] = self\n        ws_sessions_sid[socket_id] = self\n\n    def get_config(self) -> \"ChainlitConfig\":\n        \"\"\"\n        Return the config for this session: overridden if chat profile exists and has overrides, else global config.\n        \"\"\"\n        from chainlit.config import config as global_config\n\n        # If no chat profile, always fallback to global config\n        if not self.chat_profile:\n            return global_config\n        # If already computed, use self.config\n        if hasattr(self, \"config\") and self.config:\n            return self.config\n        # Try to compute overrides\n        cfg = global_config\n        if global_config.code.set_chat_profiles:\n            import asyncio\n\n            try:\n                profiles = asyncio.get_event_loop().run_until_complete(\n                    global_config.code.set_chat_profiles(self.user, self.language)\n                )\n                current_profile = next(\n                    (p for p in profiles if p.name == self.chat_profile), None\n                )\n                if current_profile and getattr(\n                    current_profile, \"config_overrides\", None\n                ):\n                    cfg = global_config.with_overrides(current_profile.config_overrides)\n            except Exception:\n                pass\n        self.config = cfg\n        return cfg\n\n    def restore(self, new_socket_id: str):\n        \"\"\"Associate a new socket id to the session.\"\"\"\n        ws_sessions_sid.pop(self.socket_id, None)\n        ws_sessions_sid[new_socket_id] = self\n        self.socket_id = new_socket_id\n        self.restored = True\n\n    async def delete(self):\n        \"\"\"Delete the session.\"\"\"\n        if self.files_dir.is_dir():\n            shutil.rmtree(self.files_dir)\n        ws_sessions_sid.pop(self.socket_id, None)\n        ws_sessions_id.pop(self.id, None)\n\n        for _, exit_stack in self.mcp_sessions.values():\n            try:\n                await exit_stack.aclose()\n            except Exception:\n                pass\n\n    async def flush_method_queue(self):\n        for method_name, queue in self.thread_queues.items():\n            while queue:\n                method, self, args, kwargs = queue.popleft()\n                try:\n                    await method(self, *args, **kwargs)\n                except Exception as e:\n                    logger.error(f\"Error while flushing {method_name}: {e}\")\n\n    @classmethod\n    def get(cls, socket_id: str):\n        \"\"\"Get session by socket id.\"\"\"\n        return ws_sessions_sid.get(socket_id)\n\n    @classmethod\n    def get_by_id(cls, session_id: str):\n        \"\"\"Get session by session id.\"\"\"\n        return ws_sessions_id.get(session_id)\n\n    @classmethod\n    def require(cls, socket_id: str):\n        \"\"\"Throws an exception if the session is not found.\"\"\"\n        if session := cls.get(socket_id):\n            return session\n        raise ValueError(\"Session not found\")\n\n\nws_sessions_sid: Dict[str, WebsocketSession] = {}\nws_sessions_id: Dict[str, WebsocketSession] = {}\n"
  },
  {
    "path": "backend/chainlit/sidebar.py",
    "content": "import asyncio\nfrom typing import List, Optional\n\nfrom chainlit.context import context\nfrom chainlit.element import ElementBased\n\n\nclass ElementSidebar:\n    \"\"\"Helper class to open/close the element sidebar server side.\n    The element sidebar accepts a title and list of elements.\"\"\"\n\n    @staticmethod\n    async def set_title(title: str):\n        \"\"\"\n        Sets the title of the element sidebar and opens it.\n\n        The sidebar will automatically open when a title is set using this method.\n\n        Args:\n            title (str): The title to display at the top of the sidebar.\n\n        Returns:\n            None: This method does not return anything.\n        \"\"\"\n        await context.emitter.emit(\"set_sidebar_title\", title)\n\n    @staticmethod\n    async def set_elements(elements: List[ElementBased], key: Optional[str] = None):\n        \"\"\"\n        Sets the elements to display in the sidebar and controls sidebar visibility.\n\n        This method sends all provided elements to the client and updates the sidebar.\n        Passing an empty list will close the sidebar, while passing at least one element\n        will open it.\n\n        Args:\n            elements (List[ElementBased]): A list of ElementBased objects to display in the sidebar.\n            key (Optional[str], optional): If the sidebar is already opened with the same key, elements will not be replaced.\n\n        Returns:\n            None: This method does not return anything.\n\n        Note:\n            This method first sends each element separately using their send() method,\n            then emits an event with all element dictionaries and the optional key.\n        \"\"\"\n        coros = [\n            element.send(for_id=element.for_id or \"\", persist=False)\n            for element in elements\n        ]\n        await asyncio.gather(*coros)\n        await context.emitter.emit(\n            \"set_sidebar_elements\",\n            {\"elements\": [el.to_dict() for el in elements], \"key\": key},\n        )\n"
  },
  {
    "path": "backend/chainlit/slack/__init__.py",
    "content": "import importlib.util\n\nif importlib.util.find_spec(\"slack_bolt\") is None:\n    raise ValueError(\n        \"The slack_bolt package is required to integrate Chainlit with a Slack app. Run `pip install slack_bolt --upgrade`\"\n    )\n"
  },
  {
    "path": "backend/chainlit/slack/app.py",
    "content": "import asyncio\nimport os\nimport re\nimport uuid\nfrom functools import partial\nfrom typing import Dict, List, Optional, Union\n\nimport httpx\nfrom slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler\nfrom slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler\nfrom slack_bolt.async_app import AsyncApp\n\nfrom chainlit.config import config\nfrom chainlit.context import ChainlitContext, HTTPSession, context, context_var\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import Element, ElementDict\nfrom chainlit.emitter import BaseChainlitEmitter\nfrom chainlit.logger import logger\nfrom chainlit.message import Message, StepDict\nfrom chainlit.types import Feedback\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.user_session import user_session\n\n\nclass SlackEmitter(BaseChainlitEmitter):\n    def __init__(\n        self,\n        session: HTTPSession,\n        app: AsyncApp,\n        channel_id: str,\n        say,\n        thread_ts: Optional[str] = None,\n    ):\n        super().__init__(session)\n        self.app = app\n        self.channel_id = channel_id\n        self.say = say\n        self.thread_ts = thread_ts\n\n    async def send_element(self, element_dict: ElementDict):\n        if element_dict.get(\"display\") != \"inline\":\n            return\n\n        persisted_file = self.session.files.get(element_dict.get(\"chainlitKey\") or \"\")\n        file: Optional[Union[bytes, str]] = None\n\n        if persisted_file:\n            file = str(persisted_file[\"path\"])\n        elif file_url := element_dict.get(\"url\"):\n            async with httpx.AsyncClient() as client:\n                response = await client.get(file_url)\n                if response.status_code == 200:\n                    file = response.content\n\n        if not file:\n            return\n\n        await self.app.client.files_upload_v2(\n            channel=self.channel_id,\n            thread_ts=self.thread_ts,\n            file=file,\n            title=element_dict.get(\"name\"),\n        )\n\n    async def send_step(self, step_dict: StepDict):\n        step_type = step_dict.get(\"type\")\n        is_assistant_message = step_type == \"assistant_message\"\n        is_empty_output = not step_dict.get(\"output\")\n\n        if is_empty_output or not is_assistant_message:\n            return\n\n        enable_feedback = get_data_layer()\n        blocks: List[Dict] = [\n            {\n                \"type\": \"section\",\n                \"text\": {\"type\": \"mrkdwn\", \"text\": step_dict[\"output\"]},\n            }\n        ]\n        if enable_feedback:\n            current_run = context.current_run\n            scorable_id = current_run.id if current_run else step_dict.get(\"id\")\n            blocks.append(\n                {\n                    \"type\": \"actions\",\n                    \"elements\": [\n                        {\n                            \"action_id\": \"thumbdown\",\n                            \"type\": \"button\",\n                            \"text\": {\n                                \"type\": \"plain_text\",\n                                \"emoji\": True,\n                                \"text\": \":thumbsdown:\",\n                            },\n                            \"value\": scorable_id,\n                        },\n                        {\n                            \"action_id\": \"thumbup\",\n                            \"type\": \"button\",\n                            \"text\": {\n                                \"type\": \"plain_text\",\n                                \"emoji\": True,\n                                \"text\": \":thumbsup:\",\n                            },\n                            \"value\": scorable_id,\n                        },\n                    ],\n                }\n            )\n        await self.say(\n            text=step_dict[\"output\"], blocks=blocks, thread_ts=self.thread_ts\n        )\n\n    async def update_step(self, step_dict: StepDict):\n        is_assistant_message = step_dict[\"type\"] == \"assistant_message\"\n\n        if not is_assistant_message:\n            return\n\n        await self.send_step(step_dict)\n\n\nslack_app = AsyncApp(\n    token=os.environ.get(\"SLACK_BOT_TOKEN\"),\n    signing_secret=os.environ.get(\"SLACK_SIGNING_SECRET\"),\n)\n\n\nasync def start_socket_mode():\n    \"\"\"\n    Initializes and starts the Slack app in Socket Mode asynchronously.\n\n    Uses the SLACK_WEBSOCKET_TOKEN from environment variables to authenticate.\n    \"\"\"\n    handler = AsyncSocketModeHandler(slack_app, os.environ.get(\"SLACK_WEBSOCKET_TOKEN\"))\n    await handler.start_async()\n\n\ndef init_slack_context(\n    session: HTTPSession,\n    slack_channel_id: str,\n    event,\n    say,\n    thread_ts: Optional[str] = None,\n) -> ChainlitContext:\n    emitter = SlackEmitter(\n        session=session,\n        app=slack_app,\n        channel_id=slack_channel_id,\n        say=say,\n        thread_ts=thread_ts,\n    )\n    context = ChainlitContext(session=session, emitter=emitter)\n    context_var.set(context)\n    user_session.set(\"slack_event\", event)\n    user_session.set(\n        \"fetch_slack_message_history\",\n        partial(\n            fetch_message_history, channel_id=slack_channel_id, thread_ts=thread_ts\n        ),\n    )\n    return context\n\n\nslack_app_handler = AsyncSlackRequestHandler(slack_app)\n\nusers_by_slack_id: Dict[str, Union[User, PersistedUser]] = {}\n\nUSER_PREFIX = \"slack_\"\n\n\nbot_user_id: Optional[str] = None\n\n\nasync def get_bot_user_id() -> Optional[str]:\n    \"\"\"Get and cache the bot's user ID.\"\"\"\n    global bot_user_id\n    if bot_user_id:\n        return bot_user_id\n\n    try:\n        result = await slack_app.client.auth_test()\n        if result.get(\"ok\"):\n            bot_user_id = result.get(\"user_id\")\n            return bot_user_id\n    except Exception as e:\n        logger.error(f\"Failed to get bot user ID: {e}\")\n\n    return None\n\n\ndef clean_content(message: str):\n    cleaned_text = re.sub(r\"<@[\\w]+>\", \"\", message).strip()\n    return cleaned_text\n\n\nasync def get_user(slack_user_id: str):\n    if slack_user_id in users_by_slack_id:\n        return users_by_slack_id[slack_user_id]\n\n    slack_user = await slack_app.client.users_info(user=slack_user_id)\n    slack_user_profile = slack_user[\"user\"][\"profile\"]\n\n    user_identifier = slack_user_profile.get(\"email\") or slack_user_id\n    user = User(identifier=USER_PREFIX + user_identifier, metadata=slack_user_profile)\n\n    users_by_slack_id[slack_user_id] = user\n\n    if data_layer := get_data_layer():\n        try:\n            persisted_user = await data_layer.create_user(user)\n            if persisted_user:\n                users_by_slack_id[slack_user_id] = persisted_user\n        except Exception as e:\n            logger.error(f\"Error creating user: {e}\")\n\n    return users_by_slack_id[slack_user_id]\n\n\nasync def fetch_message_history(\n    channel_id: str, thread_ts: Optional[str] = None, limit=30\n):\n    if not thread_ts:\n        result = await slack_app.client.conversations_history(\n            channel=channel_id, limit=limit\n        )\n    else:\n        result = await slack_app.client.conversations_replies(\n            channel=channel_id, ts=thread_ts, limit=limit\n        )\n    if result[\"ok\"]:\n        messages = result[\"messages\"]\n        return messages\n    else:\n        raise Exception(f\"Failed to fetch messages: {result['error']}\")\n\n\nasync def download_slack_file(url, token):\n    headers = {\"Authorization\": f\"Bearer {token}\"}\n    async with httpx.AsyncClient() as client:\n        response = await client.get(url, headers=headers)\n        if response.status_code == 200:\n            return response.content\n        else:\n            return None\n\n\nasync def download_slack_files(session: HTTPSession, files, token):\n    download_coros = [\n        download_slack_file(file.get(\"url_private\"), token) for file in files\n    ]\n    file_bytes_list = await asyncio.gather(*download_coros)\n    file_refs = []\n    for idx, file_bytes in enumerate(file_bytes_list):\n        if file_bytes:\n            name = files[idx].get(\"name\")\n            mime_type = files[idx].get(\"mimetype\")\n            file_ref = await session.persist_file(\n                name=name, mime=mime_type, content=file_bytes\n            )\n            file_refs.append(file_ref)\n\n    files_dicts = [\n        session.files[file[\"id\"]] for file in file_refs if file[\"id\"] in session.files\n    ]\n\n    elements = [\n        Element.from_dict(\n            {\n                \"id\": file[\"id\"],\n                \"name\": file[\"name\"],\n                \"path\": str(file[\"path\"]),\n                \"chainlitKey\": file[\"id\"],\n                \"display\": \"inline\",\n                \"type\": Element.infer_type_from_mime(file[\"type\"]),\n            }\n        )\n        for file in files_dicts\n    ]\n\n    return elements\n\n\nasync def add_reaction_if_enabled(event, emoji: str = \"eyes\"):\n    if config.features.slack.reaction_on_message_received:\n        try:\n            await slack_app.client.reactions_add(\n                channel=event[\"channel\"], timestamp=event[\"ts\"], name=emoji\n            )\n        except Exception as e:\n            logger.warning(f\"Failed to add reaction: {e}\")\n\n\nasync def process_slack_message(\n    event,\n    say,\n    thread_id: str,\n    thread_name: Optional[str] = None,\n    bind_thread_to_user=False,\n    thread_ts: Optional[str] = None,\n):\n    await add_reaction_if_enabled(event)\n\n    user = await get_user(event[\"user\"])\n\n    channel_id = event[\"channel\"]\n\n    text = event.get(\"text\")\n    slack_files = event.get(\"files\", [])\n\n    session_id = str(uuid.uuid4())\n    session = HTTPSession(\n        id=session_id,\n        thread_id=thread_id,\n        user=user,\n        client_type=\"slack\",\n    )\n\n    ctx = init_slack_context(\n        session=session,\n        slack_channel_id=channel_id,\n        event=event,\n        say=say,\n        thread_ts=thread_ts,\n    )\n\n    file_elements = await download_slack_files(\n        session, slack_files, slack_app.client.token\n    )\n\n    if on_chat_start := config.code.on_chat_start:\n        await on_chat_start()\n\n    msg = Message(\n        content=clean_content(text),\n        elements=file_elements,\n        type=\"user_message\",\n        author=user.metadata.get(\"real_name\"),\n    )\n\n    if on_message := config.code.on_message:\n        await on_message(msg)\n\n    if on_chat_end := config.code.on_chat_end:\n        await on_chat_end()\n\n    if data_layer := get_data_layer():\n        user_id = None\n        if isinstance(user, PersistedUser):\n            user_id = user.id if bind_thread_to_user else None\n\n        try:\n            await data_layer.update_thread(\n                thread_id=thread_id,\n                name=thread_name or msg.content,\n                metadata=ctx.session.to_persistable(),\n                user_id=user_id,\n            )\n        except Exception as e:\n            logger.error(f\"Error updating thread: {e}\")\n\n    await ctx.session.delete()\n\n\n@slack_app.event(\"app_home_opened\")\nasync def handle_app_home_opened(event, say):\n    pass\n\n\n@slack_app.event(\"app_mention\")\nasync def handle_app_mentions(event, say):\n    thread_ts = event.get(\"thread_ts\", event[\"ts\"])\n    thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts))\n\n    await process_slack_message(event, say, thread_id=thread_id, thread_ts=thread_ts)\n\n\n@slack_app.event(\"message\")\nasync def handle_message(message, say):\n    thread_ts = message.get(\"thread_ts\", message[\"ts\"])\n    thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts))\n\n    await process_slack_message(\n        event=message,\n        say=say,\n        thread_id=thread_id,\n        bind_thread_to_user=True,\n        thread_ts=thread_ts,\n    )\n\n\n@slack_app.event(\"reaction_added\")\nasync def handle_reaction_added(event):\n    bot_id = await get_bot_user_id()\n\n    if event.get(\"user\") == bot_id:\n        return\n\n    item = event.get(\"item\", {})\n    channel_id = item.get(\"channel\")\n    thread_ts = item.get(\"ts\")\n\n    if not channel_id:\n        logger.warning(\n            \"reaction_added event missing channel_id, skipping context setup\"\n        )\n        return\n\n    try:\n        result = await slack_app.client.conversations_replies(\n            channel=channel_id, ts=thread_ts, limit=1\n        )\n\n        if result.get(\"ok\"):\n            messages = result.get(\"messages\")\n            message = messages[0]\n            message_user = message.get(\"user\")\n            message_bot_id = message.get(\"bot_id\")\n\n            if message_user != bot_id and message_bot_id != bot_id:\n                return\n        else:\n            raise Exception(\n                f\"Failed to fetch message: {result.get('error', 'Unknown error')}\"\n            )\n\n    except Exception as e:\n        logger.warning(f\"Failed to fetch message for reaction: {e}\")\n        return\n\n    async def say(text: str = \"\", **kwargs):\n        await slack_app.client.chat_postMessage(\n            channel=channel_id, text=text, thread_ts=thread_ts, **kwargs\n        )\n\n    user = await get_user(event[\"user\"])\n\n    thread_id = (\n        str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts))\n        if thread_ts\n        else str(uuid.uuid4())\n    )\n\n    session_id = str(uuid.uuid4())\n    session = HTTPSession(\n        id=session_id,\n        thread_id=thread_id,\n        user=user,\n        client_type=\"slack\",\n    )\n\n    ctx = init_slack_context(\n        session=session,\n        slack_channel_id=channel_id,\n        event=event,\n        say=say,\n        thread_ts=thread_ts,\n    )\n\n    try:\n        if on_chat_start := config.code.on_chat_start:\n            await on_chat_start()\n\n        if on_slack_reaction_added := config.code.on_slack_reaction_added:\n            await on_slack_reaction_added(event)\n    finally:\n        await ctx.session.delete()\n\n\n@slack_app.block_action(\"thumbdown\")\nasync def thumb_down(ack, context, body):\n    await ack()\n    step_id = body[\"actions\"][0][\"value\"]\n    thread_ts = body[\"message\"][\"thread_ts\"]\n    thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts))\n\n    if data_layer := get_data_layer():\n        feedback = Feedback(forId=step_id, value=0, threadId=thread_id)\n        await data_layer.upsert_feedback(feedback)\n\n    text = body[\"message\"][\"text\"]\n    blocks = body[\"message\"][\"blocks\"]\n    updated_blocks = [block for block in blocks if block[\"type\"] != \"actions\"]\n    updated_blocks.append(\n        {\n            \"type\": \"section\",\n            \"text\": {\"type\": \"mrkdwn\", \"text\": \":thumbsdown: Feedback received.\"},\n        }\n    )\n    await context.client.chat_update(\n        channel=body[\"channel\"][\"id\"],\n        ts=body[\"container\"][\"message_ts\"],\n        text=text,\n        blocks=updated_blocks,\n    )\n\n\n@slack_app.block_action(\"thumbup\")\nasync def thumb_up(ack, context, body):\n    await ack()\n    step_id = body[\"actions\"][0][\"value\"]\n    thread_ts = body[\"message\"][\"thread_ts\"]\n    thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts))\n\n    if data_layer := get_data_layer():\n        feedback = Feedback(forId=step_id, value=1, threadId=thread_id)\n        await data_layer.upsert_feedback(feedback)\n\n    text = body[\"message\"][\"text\"]\n    blocks = body[\"message\"][\"blocks\"]\n    updated_blocks = [block for block in blocks if block[\"type\"] != \"actions\"]\n    updated_blocks.append(\n        {\n            \"type\": \"section\",\n            \"text\": {\"type\": \"mrkdwn\", \"text\": \":thumbsup: Feedback received.\"},\n        }\n    )\n    await context.client.chat_update(\n        channel=body[\"channel\"][\"id\"],\n        ts=body[\"container\"][\"message_ts\"],\n        text=text,\n        blocks=updated_blocks,\n    )\n"
  },
  {
    "path": "backend/chainlit/socket.py",
    "content": "import asyncio\nimport json\nfrom typing import Any, Dict, Literal, Optional, Tuple, TypedDict, Union\nfrom urllib.parse import unquote\n\nfrom starlette.requests import cookie_parser\nfrom typing_extensions import TypeAlias\n\nfrom chainlit.auth import (\n    get_current_user,\n    get_token_from_cookies,\n    require_login,\n)\nfrom chainlit.chat_context import chat_context\nfrom chainlit.config import ChainlitConfig, config\nfrom chainlit.context import init_ws_context\nfrom chainlit.data import get_data_layer\nfrom chainlit.logger import logger\nfrom chainlit.message import ErrorMessage, Message\nfrom chainlit.server import sio\nfrom chainlit.session import ClientType, WebsocketSession\nfrom chainlit.types import (\n    InputAudioChunk,\n    InputAudioChunkPayload,\n    MessagePayload,\n)\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.user_session import user_sessions\n\nWSGIEnvironment: TypeAlias = dict[str, Any]\n\n\nclass WebSocketSessionAuth(TypedDict):\n    sessionId: str\n    userEnv: str | None\n    clientType: ClientType\n    chatProfile: str | None\n    threadId: str | None\n\n\ndef restore_existing_session(sid, session_id, emit_fn, emit_call_fn, environ):\n    \"\"\"Restore a session from the sessionId provided by the client.\"\"\"\n    if session := WebsocketSession.get_by_id(session_id):\n        session.restore(new_socket_id=sid)\n        session.emit = emit_fn\n        session.emit_call = emit_call_fn\n        session.environ = environ\n        return True\n    return False\n\n\nasync def persist_user_session(thread_id: str, metadata: Dict):\n    if data_layer := get_data_layer():\n        await data_layer.update_thread(thread_id=thread_id, metadata=metadata)\n\n\nasync def resume_thread(session: WebsocketSession):\n    data_layer = get_data_layer()\n    if not data_layer or not session.user or not session.thread_id_to_resume:\n        return\n    thread = await data_layer.get_thread(thread_id=session.thread_id_to_resume)\n    if not thread:\n        return\n\n    author = thread.get(\"userIdentifier\")\n    user_is_author = author == session.user.identifier\n\n    if user_is_author:\n        metadata = thread.get(\"metadata\") or {}\n        if isinstance(metadata, str):\n            metadata = json.loads(metadata)\n        user_sessions[session.id] = metadata.copy()\n        if chat_profile := metadata.get(\"chat_profile\"):\n            session.chat_profile = chat_profile\n        if chat_settings := metadata.get(\"chat_settings\"):\n            session.chat_settings = chat_settings\n\n        return thread\n\n\ndef load_user_env(user_env):\n    if user_env:\n        user_env_dict = json.loads(user_env)\n    # Check user env\n    if config.project.user_env:\n        if not user_env_dict:\n            raise ConnectionRefusedError(\"Missing user environment variables\")\n        # Check if requested user environment variables are provided\n        for key in config.project.user_env:\n            if key not in user_env_dict:\n                raise ConnectionRefusedError(\n                    \"Missing user environment variable: \" + key\n                )\n    return user_env_dict\n\n\ndef _get_token_from_cookie(environ: WSGIEnvironment) -> Optional[str]:\n    if cookie_header := environ.get(\"HTTP_COOKIE\", None):\n        cookies = cookie_parser(cookie_header)\n        return get_token_from_cookies(cookies)\n\n    return None\n\n\ndef _get_token(environ: WSGIEnvironment) -> Optional[str]:\n    \"\"\"Take WSGI environ, return access token.\"\"\"\n    return _get_token_from_cookie(environ)\n\n\nasync def _authenticate_connection(\n    environ: WSGIEnvironment,\n) -> Union[Tuple[Union[User, PersistedUser], str], Tuple[None, None]]:\n    if token := _get_token(environ):\n        user = await get_current_user(token=token)\n        if user:\n            return user, token\n\n    return None, None\n\n\n@sio.on(\"connect\")  # pyright: ignore [reportOptionalCall]\nasync def connect(sid: str, environ: WSGIEnvironment, auth: WebSocketSessionAuth):\n    user: User | PersistedUser | None = None\n    token: str | None = None\n    thread_id = auth.get(\"threadId\", None)\n\n    if require_login():\n        try:\n            user, token = await _authenticate_connection(environ)\n        except Exception as e:\n            logger.exception(\"Exception authenticating connection: %s\", e)\n\n        if not user:\n            logger.error(\"Authentication failed in websocket connect.\")\n            raise ConnectionRefusedError(\"authentication failed\")\n\n        if thread_id:\n            if data_layer := get_data_layer():\n                thread = await data_layer.get_thread(thread_id)\n                if thread and not (thread[\"userIdentifier\"] == user.identifier):\n                    logger.error(\"Authorization for the thread failed.\")\n                    raise ConnectionRefusedError(\"authorization failed\")\n\n    # Session scoped function to emit to the client\n    def emit_fn(event, data):\n        return sio.emit(event, data, to=sid)\n\n    # Session scoped function to emit to the client and wait for a response\n    def emit_call_fn(event: Literal[\"ask\", \"call_fn\"], data, timeout):\n        return sio.call(event, data, timeout=timeout, to=sid)\n\n    session_id = auth[\"sessionId\"]\n    if restore_existing_session(sid, session_id, emit_fn, emit_call_fn, environ):\n        return True\n\n    user_env_string = auth.get(\"userEnv\", None)\n    user_env = load_user_env(user_env_string)\n\n    client_type = auth[\"clientType\"]\n    url_encoded_chat_profile = auth.get(\"chatProfile\", None)\n    chat_profile = (\n        unquote(url_encoded_chat_profile) if url_encoded_chat_profile else None\n    )\n\n    WebsocketSession(\n        id=session_id,\n        socket_id=sid,\n        emit=emit_fn,\n        emit_call=emit_call_fn,\n        client_type=client_type,\n        user_env=user_env,\n        user=user,\n        token=token,\n        chat_profile=chat_profile,\n        thread_id=thread_id,\n        environ=environ,\n    )\n\n    return True\n\n\n@sio.on(\"connection_successful\")  # pyright: ignore [reportOptionalCall]\nasync def connection_successful(sid):\n    context = init_ws_context(sid)\n\n    await context.emitter.task_end()\n    await context.emitter.clear(\"clear_ask\")\n    await context.emitter.clear(\"clear_call_fn\")\n\n    if context.session.restored and not context.session.has_first_interaction:\n        if config.code.on_chat_start:\n            task = asyncio.create_task(config.code.on_chat_start())\n            context.session.current_task = task\n        return\n\n    if context.session.thread_id_to_resume and config.code.on_chat_resume:\n        thread = await resume_thread(context.session)\n        if thread:\n            context.session.has_first_interaction = True\n            await context.emitter.emit(\n                \"first_interaction\",\n                {\"interaction\": \"resume\", \"thread_id\": thread.get(\"id\")},\n            )\n            await config.code.on_chat_resume(thread)\n\n            for step in thread.get(\"steps\", []):\n                if \"message\" in step[\"type\"]:\n                    chat_context.add(Message.from_dict(step))\n\n            await context.emitter.resume_thread(thread)\n            return\n        else:\n            await context.emitter.send_resume_thread_error(\"Thread not found.\")\n\n    if config.code.on_chat_start:\n        task = asyncio.create_task(config.code.on_chat_start())\n        context.session.current_task = task\n\n\n@sio.on(\"clear_session\")  # pyright: ignore [reportOptionalCall]\nasync def clean_session(sid):\n    session = WebsocketSession.get(sid)\n    if session:\n        session.to_clear = True\n\n\n@sio.on(\"disconnect\")  # pyright: ignore [reportOptionalCall]\nasync def disconnect(sid):\n    session = WebsocketSession.get(sid)\n\n    if not session:\n        return\n\n    init_ws_context(session)\n\n    if config.code.on_chat_end:\n        await config.code.on_chat_end()\n\n    if session.thread_id and session.has_first_interaction:\n        await persist_user_session(session.thread_id, session.to_persistable())\n\n    async def clear(_sid):\n        if session := WebsocketSession.get(_sid):\n            # Clean up the user session\n            if session.id in user_sessions:\n                user_sessions.pop(session.id)\n            # Clean up the session\n            await session.delete()\n\n    if session.to_clear:\n        await clear(sid)\n    else:\n\n        async def clear_on_timeout(_sid):\n            await asyncio.sleep(config.project.session_timeout)\n            await clear(_sid)\n\n        asyncio.ensure_future(clear_on_timeout(sid))\n\n\n@sio.on(\"stop\")  # pyright: ignore [reportOptionalCall]\nasync def stop(sid):\n    if session := WebsocketSession.get(sid):\n        init_ws_context(session)\n        await Message(content=\"Task manually stopped.\").send()\n\n        if session.current_task:\n            session.current_task.cancel()\n\n        if config.code.on_stop:\n            await config.code.on_stop()\n\n\nasync def process_message(session: WebsocketSession, payload: MessagePayload):\n    \"\"\"Process a message from the user.\"\"\"\n    try:\n        context = init_ws_context(session)\n        await context.emitter.task_start()\n        message = await context.emitter.process_message(payload)\n\n        if config.code.on_message:\n            await asyncio.sleep(0.001)\n            await config.code.on_message(message)\n    except asyncio.CancelledError:\n        pass\n    except Exception as e:\n        logger.exception(e)\n        await ErrorMessage(\n            author=\"Error\", content=str(e) or e.__class__.__name__\n        ).send()\n    finally:\n        await context.emitter.task_end()\n\n\n@sio.on(\"edit_message\")  # pyright: ignore [reportOptionalCall]\nasync def edit_message(sid, payload: MessagePayload):\n    \"\"\"Handle a message sent by the User.\"\"\"\n    session = WebsocketSession.require(sid)\n    context = init_ws_context(session)\n\n    messages = chat_context.get()\n\n    orig_message = None\n\n    for message in messages:\n        if orig_message:\n            await message.remove()\n\n        if message.id == payload[\"message\"][\"id\"]:\n            message.content = payload[\"message\"][\"output\"]\n            await message.update()\n            orig_message = message\n\n    await context.emitter.task_start()\n\n    if config.code.on_message:\n        try:\n            await config.code.on_message(orig_message)\n        except asyncio.CancelledError:\n            pass\n        finally:\n            await context.emitter.task_end()\n\n\n@sio.on(\"message_favorite\")  # pyright: ignore [reportOptionalCall]\nasync def message_favorite(sid, payload: MessagePayload):\n    \"\"\"Handle a message favorite toggle.\"\"\"\n    session = WebsocketSession.require(sid)\n    context = init_ws_context(session)\n    data_layer = get_data_layer()\n\n    if not config.features.favorites or not session.user:\n        return\n\n    payload_message = payload[\"message\"]\n    payload_metadata = payload_message.get(\"metadata\") or {}\n    favorite = bool(payload_metadata.get(\"favorite\", False))\n\n    step_dict = None\n\n    if favorite:\n        for message in chat_context.get():\n            if message.id == payload_message[\"id\"]:\n                message.metadata = message.metadata or {}\n                message.metadata[\"favorite\"] = favorite\n                step_dict = message.to_dict()\n                break\n    elif data_layer:\n        favorites = await data_layer.get_favorite_steps(session.user.id)\n        for fav in favorites:\n            if fav[\"id\"] == payload_message[\"id\"]:\n                step_dict = fav\n                break\n\n    if step_dict is None:\n        logger.error(\"Could not find step to update favorite status.\")\n        return\n\n    created_at = step_dict.get(\"createdAt\")\n    if created_at and not created_at.endswith(\"Z\"):\n        step_dict[\"createdAt\"] = f\"{created_at}Z\"\n\n    if data_layer:\n        step_dict = await data_layer.set_step_favorite(step_dict, favorite)\n\n    await context.emitter.update_step(step_dict)\n    await fetch_favorites(sid)\n\n\n@sio.on(\"fetch_favorites\")  # pyright: ignore [reportOptionalCall]\nasync def fetch_favorites(sid):\n    session = WebsocketSession.require(sid)\n    context = init_ws_context(session)\n    if session.user and config.features.favorites:\n        if data_layer := get_data_layer():\n            favorites = await data_layer.get_favorite_steps(session.user.id)\n            await context.emitter.set_favorites(favorites)\n\n\n@sio.on(\"client_message\")  # pyright: ignore [reportOptionalCall]\nasync def message(sid, payload: MessagePayload):\n    \"\"\"Handle a message sent by the User.\"\"\"\n    session = WebsocketSession.require(sid)\n\n    task = asyncio.create_task(process_message(session, payload))\n    session.current_task = task\n\n\n@sio.on(\"window_message\")  # pyright: ignore [reportOptionalCall]\nasync def window_message(sid, data):\n    \"\"\"Handle a message send by the host window.\"\"\"\n    session = WebsocketSession.require(sid)\n    init_ws_context(session)\n\n    if config.code.on_window_message:\n        try:\n            await config.code.on_window_message(data)\n        except asyncio.CancelledError:\n            pass\n\n\n@sio.on(\"audio_start\")  # pyright: ignore [reportOptionalCall]\nasync def audio_start(sid):\n    \"\"\"Handle audio init.\"\"\"\n    session = WebsocketSession.require(sid)\n\n    context = init_ws_context(session)\n    config: ChainlitConfig = session.get_config()  # type: ignore\n\n    if config.features.audio and config.features.audio.enabled:\n        connected = bool(await config.code.on_audio_start())\n        connection_state = \"on\" if connected else \"off\"\n        await context.emitter.update_audio_connection(connection_state)\n\n\n@sio.on(\"audio_chunk\")\nasync def audio_chunk(sid, payload: InputAudioChunkPayload):\n    \"\"\"Handle an audio chunk sent by the user.\"\"\"\n    session = WebsocketSession.require(sid)\n\n    init_ws_context(session)\n\n    config: ChainlitConfig = session.get_config()\n\n    if (\n        config.features.audio\n        and config.features.audio.enabled\n        and config.code.on_audio_chunk\n    ):\n        asyncio.create_task(config.code.on_audio_chunk(InputAudioChunk(**payload)))\n\n\n@sio.on(\"audio_end\")\nasync def audio_end(sid):\n    \"\"\"Handle the end of the audio stream.\"\"\"\n    session = WebsocketSession.require(sid)\n\n    try:\n        context = init_ws_context(session)\n        await context.emitter.task_start()\n\n        if not session.has_first_interaction:\n            session.has_first_interaction = True\n            asyncio.create_task(context.emitter.init_thread(\"audio\"))\n\n        config: ChainlitConfig = session.get_config()  # type: ignore\n\n        if config.features.audio and config.features.audio.enabled:\n            await config.code.on_audio_end()\n\n    except asyncio.CancelledError:\n        pass\n    except Exception as e:\n        logger.exception(e)\n        await ErrorMessage(\n            author=\"Error\", content=str(e) or e.__class__.__name__\n        ).send()\n    finally:\n        await context.emitter.task_end()\n\n\n@sio.on(\"chat_settings_change\")\nasync def change_settings(sid, settings: Dict[str, Any]):\n    \"\"\"Handle change settings submit from the UI.\"\"\"\n    context = init_ws_context(sid)\n\n    for key, value in settings.items():\n        context.session.chat_settings[key] = value\n\n    if config.code.on_settings_update:\n        await config.code.on_settings_update(settings)\n\n\n@sio.on(\"chat_settings_edit\")\nasync def edit_settings(sid, settings: Dict[str, Any]):\n    \"\"\"Handle change settings edit from the UI (on the fly).\"\"\"\n    init_ws_context(sid)\n\n    if config.code.on_settings_edit:\n        await config.code.on_settings_edit(settings)\n"
  },
  {
    "path": "backend/chainlit/step.py",
    "content": "import asyncio\nimport inspect\nimport json\nimport time\nimport uuid\nfrom copy import deepcopy\nfrom functools import wraps\nfrom typing import Callable, Dict, List, Optional, TypedDict, Union\n\nfrom literalai import BaseGeneration\nfrom literalai.observability.step import StepType, TrueStepType\n\nfrom chainlit.config import config\nfrom chainlit.context import CL_RUN_NAMES, context, local_steps\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import Element\nfrom chainlit.logger import logger\nfrom chainlit.types import FeedbackDict\nfrom chainlit.utils import utc_now\n\n\ndef check_add_step_in_cot(step: \"Step\"):\n    is_message = step.type in [\n        \"user_message\",\n        \"assistant_message\",\n    ]\n    is_cl_run = step.name in CL_RUN_NAMES and step.type == \"run\"\n    if config.ui.cot == \"hidden\" and not is_message and not is_cl_run:\n        return False\n    return True\n\n\ndef stub_step(step: \"Step\") -> \"StepDict\":\n    return {\n        \"type\": step.type,\n        \"name\": step.name,\n        \"id\": step.id,\n        \"parentId\": step.parent_id,\n        \"threadId\": step.thread_id,\n        \"input\": \"\",\n        \"output\": \"\",\n    }\n\n\nclass StepDict(TypedDict, total=False):\n    name: str\n    type: StepType\n    id: str\n    threadId: str\n    parentId: Optional[str]\n    command: Optional[str]\n    modes: Optional[Dict[str, str]]\n    streaming: bool\n    waitForAnswer: Optional[bool]\n    isError: Optional[bool]\n    metadata: Dict\n    tags: Optional[List[str]]\n    input: str\n    output: str\n    createdAt: Optional[str]\n    start: Optional[str]\n    end: Optional[str]\n    generation: Optional[Dict]\n    showInput: Optional[Union[bool, str]]\n    defaultOpen: Optional[bool]\n    autoCollapse: Optional[bool]\n    language: Optional[str]\n    feedback: Optional[FeedbackDict]\n\n\ndef flatten_args_kwargs(func, args, kwargs):\n    signature = inspect.signature(func)\n    bound_arguments = signature.bind(*args, **kwargs)\n    bound_arguments.apply_defaults()\n    return {k: deepcopy(v) for k, v in bound_arguments.arguments.items()}\n\n\ndef step(\n    original_function: Optional[Callable] = None,\n    *,\n    name: Optional[str] = \"\",\n    type: TrueStepType = \"undefined\",\n    id: Optional[str] = None,\n    parent_id: Optional[str] = None,\n    tags: Optional[List[str]] = None,\n    metadata: Optional[Dict] = None,\n    language: Optional[str] = None,\n    show_input: Union[bool, str] = \"json\",\n    default_open: bool = False,\n    auto_collapse: bool = False,\n):\n    \"\"\"Step decorator for async and sync functions.\"\"\"\n\n    def wrapper(func: Callable):\n        nonlocal name\n        if not name:\n            name = func.__name__\n\n        # Handle async decorator\n\n        if inspect.iscoroutinefunction(func):\n\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                async with Step(\n                    type=type,\n                    name=name,\n                    id=id,\n                    parent_id=parent_id,\n                    tags=tags,\n                    language=language,\n                    show_input=show_input,\n                    default_open=default_open,\n                    auto_collapse=auto_collapse,\n                    metadata=metadata,\n                ) as step:\n                    try:\n                        step.input = flatten_args_kwargs(func, args, kwargs)\n                    except Exception as e:\n                        logger.exception(e)\n                    result = await func(*args, **kwargs)\n                    try:\n                        if result and not step.output:\n                            step.output = result\n                    except Exception as e:\n                        step.is_error = True\n                        step.output = str(e)\n                    return result\n\n            return async_wrapper\n        else:\n            # Handle sync decorator\n            @wraps(func)\n            def sync_wrapper(*args, **kwargs):\n                with Step(\n                    type=type,\n                    name=name,\n                    id=id,\n                    parent_id=parent_id,\n                    tags=tags,\n                    language=language,\n                    show_input=show_input,\n                    default_open=default_open,\n                    auto_collapse=auto_collapse,\n                    metadata=metadata,\n                ) as step:\n                    try:\n                        step.input = flatten_args_kwargs(func, args, kwargs)\n                    except Exception as e:\n                        logger.exception(e)\n                    result = func(*args, **kwargs)\n                    try:\n                        if result and not step.output:\n                            step.output = result\n                    except Exception as e:\n                        step.is_error = True\n                        step.output = str(e)\n                    return result\n\n            return sync_wrapper\n\n    func = original_function\n    if not func:\n        return wrapper\n    else:\n        return wrapper(func)\n\n\nclass Step:\n    # Constructor\n    name: str\n    type: TrueStepType\n    id: str\n    parent_id: Optional[str]\n\n    streaming: bool\n    persisted: bool\n\n    show_input: Union[bool, str]\n\n    is_error: Optional[bool]\n    metadata: Dict\n    tags: Optional[List[str]]\n    thread_id: str\n    created_at: Union[str, None]\n    start: Union[str, None]\n    end: Union[str, None]\n    generation: Optional[BaseGeneration]\n    language: Optional[str]\n    default_open: Optional[bool]\n    auto_collapse: Optional[bool]\n    elements: Optional[List[Element]]\n    fail_on_persist_error: bool\n\n    def __init__(\n        self,\n        name: Optional[str] = config.ui.name,\n        type: TrueStepType = \"undefined\",\n        id: Optional[str] = None,\n        parent_id: Optional[str] = None,\n        elements: Optional[List[Element]] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n        language: Optional[str] = None,\n        default_open: Optional[bool] = False,\n        auto_collapse: Optional[bool] = False,\n        show_input: Union[bool, str] = \"json\",\n        thread_id: Optional[str] = None,\n    ):\n        time.sleep(0.001)\n        self._input = \"\"\n        self._output = \"\"\n        self.thread_id = thread_id or context.session.thread_id\n        self.name = name or \"\"\n        self.type = type\n        self.id = id or str(uuid.uuid4())\n        self.metadata = metadata or {}\n        self.tags = tags\n        self.is_error = False\n        self.show_input = show_input\n        self.parent_id = parent_id\n\n        self.language = language\n        self.default_open = default_open\n        self.auto_collapse = auto_collapse\n        self.generation = None\n        self.elements = elements or []\n\n        self.created_at = utc_now()\n        self.start = None\n        self.end = None\n\n        self.streaming = False\n        self.persisted = False\n        self.fail_on_persist_error = False\n\n    def _clean_content(self, content):\n        \"\"\"\n        Recursively checks and converts bytes objects in content.\n        \"\"\"\n\n        def handle_bytes(item):\n            if isinstance(item, bytes):\n                return \"STRIPPED_BINARY_DATA\"\n            elif isinstance(item, dict):\n                return {k: handle_bytes(v) for k, v in item.items()}\n            elif isinstance(item, list):\n                return [handle_bytes(i) for i in item]\n            elif isinstance(item, tuple):\n                return tuple(handle_bytes(i) for i in item)\n            return item\n\n        return handle_bytes(content)\n\n    def _process_content(self, content, set_language=False):\n        if content is None:\n            return \"\"\n        content = self._clean_content(content)\n\n        if (\n            isinstance(content, dict)\n            or isinstance(content, list)\n            or isinstance(content, tuple)\n        ):\n            try:\n                processed_content = json.dumps(content, indent=4, ensure_ascii=False)\n                if set_language:\n                    self.language = \"json\"\n            except TypeError:\n                processed_content = str(content).replace(\"\\\\n\", \"\\n\")\n                if set_language:\n                    self.language = \"text\"\n        elif isinstance(content, str):\n            processed_content = content\n        else:\n            processed_content = str(content).replace(\"\\\\n\", \"\\n\")\n            if set_language:\n                self.language = \"text\"\n        return processed_content\n\n    @property\n    def input(self):\n        return self._input\n\n    @input.setter\n    def input(self, content: Union[Dict, str]):\n        self._input = self._process_content(content, set_language=False)\n\n    @property\n    def output(self):\n        return self._output\n\n    @output.setter\n    def output(self, content: Union[Dict, str]):\n        self._output = self._process_content(content, set_language=True)\n\n    def to_dict(self) -> StepDict:\n        _dict: StepDict = {\n            \"name\": self.name,\n            \"type\": self.type,\n            \"id\": self.id,\n            \"threadId\": self.thread_id,\n            \"parentId\": self.parent_id,\n            \"streaming\": self.streaming,\n            \"metadata\": self.metadata,\n            \"tags\": self.tags,\n            \"input\": self.input,\n            \"isError\": self.is_error,\n            \"output\": self.output,\n            \"createdAt\": self.created_at,\n            \"start\": self.start,\n            \"end\": self.end,\n            \"language\": self.language,\n            \"defaultOpen\": self.default_open,\n            \"autoCollapse\": self.auto_collapse,\n            \"showInput\": self.show_input,\n            \"generation\": self.generation.to_dict() if self.generation else None,\n        }\n        return _dict\n\n    async def update(self):\n        \"\"\"\n        Update a step already sent to the UI.\n        \"\"\"\n        if self.streaming:\n            self.streaming = False\n\n        step_dict = self.to_dict()\n        data_layer = get_data_layer()\n\n        if data_layer:\n            try:\n                asyncio.create_task(data_layer.update_step(step_dict.copy()))\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist step update: {e!s}\")\n\n        tasks = [el.send(for_id=self.id) for el in self.elements]\n        await asyncio.gather(*tasks)\n\n        if not check_add_step_in_cot(self):\n            await context.emitter.update_step(stub_step(self))\n        else:\n            await context.emitter.update_step(step_dict)\n\n        return True\n\n    async def remove(self):\n        \"\"\"\n        Remove a step already sent to the UI.\n        \"\"\"\n        step_dict = self.to_dict()\n        data_layer = get_data_layer()\n\n        if data_layer:\n            try:\n                asyncio.create_task(data_layer.delete_step(self.id))\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist step deletion: {e!s}\")\n\n        await context.emitter.delete_step(step_dict)\n\n        return True\n\n    async def send(self):\n        if self.persisted:\n            return self\n\n        if config.code.author_rename:\n            self.name = await config.code.author_rename(self.name)\n\n        if self.streaming:\n            self.streaming = False\n\n        step_dict = self.to_dict()\n\n        data_layer = get_data_layer()\n\n        if data_layer:\n            try:\n                asyncio.create_task(data_layer.create_step(step_dict.copy()))\n                self.persisted = True\n            except Exception as e:\n                if self.fail_on_persist_error:\n                    raise e\n                logger.error(f\"Failed to persist step creation: {e!s}\")\n\n        tasks = [el.send(for_id=self.id) for el in self.elements]\n        await asyncio.gather(*tasks)\n\n        if not check_add_step_in_cot(self):\n            await context.emitter.send_step(stub_step(self))\n        else:\n            await context.emitter.send_step(step_dict)\n\n        return self\n\n    async def stream_token(self, token: str, is_sequence=False, is_input=False):\n        \"\"\"\n        Sends a token to the UI.\n        Once all tokens have been streamed, call .send() to end the stream and persist the step if persistence is enabled.\n        \"\"\"\n        if not token:\n            return\n\n        if is_sequence:\n            if is_input:\n                self.input = token\n            else:\n                self.output = token\n        else:\n            if is_input:\n                self.input += token\n            else:\n                self.output += token\n\n        assert self.id\n\n        if not check_add_step_in_cot(self):\n            await context.emitter.send_step(stub_step(self))\n            return\n\n        if not self.streaming:\n            self.streaming = True\n            step_dict = self.to_dict()\n            await context.emitter.stream_start(step_dict)\n        else:\n            await context.emitter.send_token(\n                id=self.id, token=token, is_sequence=is_sequence, is_input=is_input\n            )\n\n    # Handle parameter less decorator\n    def __call__(self, func):\n        return step(\n            original_function=func,\n            type=self.type,\n            name=self.name,\n            id=self.id,\n            parent_id=self.parent_id,\n            thread_id=self.thread_id,\n        )\n\n    # Handle Context Manager Protocol\n    async def __aenter__(self):\n        self.start = utc_now()\n        previous_steps = local_steps.get() or []\n        parent_step = previous_steps[-1] if previous_steps else None\n\n        if not self.parent_id:\n            if parent_step:\n                self.parent_id = parent_step.id\n        local_steps.set(previous_steps + [self])\n        await self.send()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        self.end = utc_now()\n\n        if exc_type:\n            self.output = str(exc_val)\n            self.is_error = True\n\n        current_steps = local_steps.get()\n        if current_steps and self in current_steps:\n            current_steps.remove(self)\n            local_steps.set(current_steps)\n\n        await self.update()\n\n    def __enter__(self):\n        self.start = utc_now()\n\n        previous_steps = local_steps.get() or []\n        parent_step = previous_steps[-1] if previous_steps else None\n\n        if not self.parent_id:\n            if parent_step:\n                self.parent_id = parent_step.id\n        local_steps.set(previous_steps + [self])\n\n        asyncio.create_task(self.send())\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        self.end = utc_now()\n\n        if exc_type:\n            self.output = str(exc_val)\n            self.is_error = True\n\n        current_steps = local_steps.get()\n        if current_steps and self in current_steps:\n            current_steps.remove(self)\n            local_steps.set(current_steps)\n\n        asyncio.create_task(self.update())\n"
  },
  {
    "path": "backend/chainlit/sync.py",
    "content": "import sys\nfrom typing import Any, Coroutine, TypeVar\n\nif sys.version_info >= (3, 10):\n    from typing import ParamSpec\nelse:\n    from typing_extensions import ParamSpec\n\nimport asyncio\nimport threading\n\nfrom asyncer import asyncify\nfrom syncer import sync\n\nfrom chainlit.context import context_var\n\nmake_async = asyncify\n\nT_Retval = TypeVar(\"T_Retval\")\nT_ParamSpec = ParamSpec(\"T_ParamSpec\")\nT = TypeVar(\"T\")\n\n\ndef run_sync(co: Coroutine[Any, Any, T_Retval]) -> T_Retval:\n    \"\"\"Run the coroutine synchronously.\"\"\"\n\n    # Copy the current context\n    current_context = context_var.get()\n\n    # Define a wrapper coroutine that sets the context before running the original coroutine\n    async def context_preserving_coroutine():\n        # Set the copied context to the coroutine\n        context_var.set(current_context)\n        return await co\n\n    # Execute from the main thread in the main event loop\n    if threading.current_thread() == threading.main_thread():\n        return sync(context_preserving_coroutine())\n    else:  # Execute from a thread in the main event loop\n        result = asyncio.run_coroutine_threadsafe(\n            context_preserving_coroutine(), loop=current_context.loop\n        )\n        return result.result()\n"
  },
  {
    "path": "backend/chainlit/teams/__init__.py",
    "content": "import importlib.util\n\nif importlib.util.find_spec(\"botbuilder\") is None:\n    raise ValueError(\n        \"The botbuilder-core package is required to integrate Chainlit with a Slack app. Run `pip install botbuilder-core --upgrade`\"\n    )\n"
  },
  {
    "path": "backend/chainlit/teams/app.py",
    "content": "import asyncio\nimport base64\nimport mimetypes\nimport os\nimport uuid\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union\n\nimport filetype\n\nif TYPE_CHECKING:\n    from botbuilder.core import TurnContext\n    from botbuilder.schema import Activity\n\nimport httpx\nfrom botbuilder.core import (\n    BotFrameworkAdapter,\n    BotFrameworkAdapterSettings,\n    MessageFactory,\n    TurnContext,\n)\nfrom botbuilder.schema import (\n    ActionTypes,\n    Activity,\n    ActivityTypes,\n    Attachment,\n    CardAction,\n    ChannelAccount,\n    HeroCard,\n)\n\nfrom chainlit.config import config\nfrom chainlit.context import ChainlitContext, HTTPSession, context, context_var\nfrom chainlit.data import get_data_layer\nfrom chainlit.element import Element, ElementDict\nfrom chainlit.emitter import BaseChainlitEmitter\nfrom chainlit.logger import logger\nfrom chainlit.message import Message, StepDict\nfrom chainlit.types import Feedback\nfrom chainlit.user import PersistedUser, User\nfrom chainlit.user_session import user_session\n\n\nclass TeamsEmitter(BaseChainlitEmitter):\n    def __init__(self, session: HTTPSession, turn_context: TurnContext):\n        super().__init__(session)\n        self.turn_context = turn_context\n\n    async def send_element(self, element_dict: ElementDict):\n        if element_dict.get(\"display\") != \"inline\":\n            return\n\n        persisted_file = self.session.files.get(element_dict.get(\"chainlitKey\") or \"\")\n        attachment: Optional[Attachment] = None\n        mime: Optional[str] = None\n\n        element_name: str = element_dict.get(\"name\", \"Untitled\")\n\n        if mime:\n            file_extension = mimetypes.guess_extension(mime)\n            if file_extension:\n                element_name += file_extension\n\n        if persisted_file:\n            mime = element_dict.get(\"mime\")\n            with open(persisted_file[\"path\"], \"rb\") as file:\n                dencoded_string = base64.b64encode(file.read()).decode()\n                content_url = f\"data:{mime};base64,{dencoded_string}\"\n                attachment = Attachment(\n                    content_type=mime, content_url=content_url, name=element_name\n                )\n\n        elif url := element_dict.get(\"url\"):\n            attachment = Attachment(\n                content_type=mime, content_url=url, name=element_name\n            )\n\n        if not attachment:\n            return\n\n        await self.turn_context.send_activity(Activity(attachments=[attachment]))\n\n    async def send_step(self, step_dict: StepDict):\n        if not step_dict[\"type\"] == \"assistant_message\":\n            return\n\n        step_type = step_dict.get(\"type\")\n        is_message = step_type in [\n            \"user_message\",\n            \"assistant_message\",\n        ]\n        is_empty_output = not step_dict.get(\"output\")\n\n        if is_empty_output or not is_message:\n            return\n        else:\n            reply = MessageFactory.text(step_dict[\"output\"])\n            enable_feedback = get_data_layer()\n            if enable_feedback:\n                current_run = context.current_run\n                scorable_id = current_run.id if current_run else step_dict[\"id\"]\n                like_button = CardAction(\n                    type=ActionTypes.message_back,\n                    title=\"👍\",\n                    text=\"like\",\n                    value={\"feedback\": \"like\", \"step_id\": scorable_id},\n                )\n                dislike_button = CardAction(\n                    type=ActionTypes.message_back,\n                    title=\"👎\",\n                    text=\"dislike\",\n                    value={\"feedback\": \"dislike\", \"step_id\": scorable_id},\n                )\n                card = HeroCard(buttons=[like_button, dislike_button])\n                attachment = Attachment(\n                    content_type=\"application/vnd.microsoft.card.hero\", content=card\n                )\n                reply.attachments = [attachment]\n\n            await self.turn_context.send_activity(reply)\n\n    async def update_step(self, step_dict: StepDict):\n        if not step_dict[\"type\"] == \"assistant_message\":\n            return\n\n        await self.send_step(step_dict)\n\n\nadapter_settings = BotFrameworkAdapterSettings(\n    app_id=os.environ.get(\"TEAMS_APP_ID\"),\n    app_password=os.environ.get(\"TEAMS_APP_PASSWORD\"),\n)\nadapter = BotFrameworkAdapter(adapter_settings)\n\n\ndef init_teams_context(\n    session: HTTPSession,\n    turn_context: TurnContext,\n) -> ChainlitContext:\n    emitter = TeamsEmitter(session=session, turn_context=turn_context)\n    context = ChainlitContext(session=session, emitter=emitter)\n    context_var.set(context)\n    user_session.set(\"teams_turn_context\", turn_context)\n    return context\n\n\nusers_by_teams_id: Dict[str, Union[User, PersistedUser]] = {}\n\nUSER_PREFIX = \"teams_\"\n\n\nasync def get_user(teams_user: ChannelAccount):\n    if teams_user.id in users_by_teams_id:\n        return users_by_teams_id[teams_user.id]\n\n    metadata = {\n        \"name\": teams_user.name,\n        \"id\": teams_user.id,\n    }\n    user = User(identifier=USER_PREFIX + str(teams_user.name), metadata=metadata)\n\n    users_by_teams_id[teams_user.id] = user\n\n    if data_layer := get_data_layer():\n        try:\n            persisted_user = await data_layer.create_user(user)\n            if persisted_user:\n                users_by_teams_id[teams_user.id] = persisted_user\n        except Exception as e:\n            logger.error(f\"Error creating user: {e}\")\n\n    return users_by_teams_id[teams_user.id]\n\n\nasync def download_teams_file(url: str):\n    async with httpx.AsyncClient() as client:\n        response = await client.get(url)\n        if response.status_code == 200:\n            return response.content\n        else:\n            return None\n\n\nasync def download_teams_files(\n    session: HTTPSession, attachments: Optional[List[Attachment]] = None\n):\n    if not attachments:\n        return []\n\n    attachments = [\n        attachment for attachment in attachments if isinstance(attachment.content, dict)\n    ]\n    download_coros = [\n        download_teams_file(attachment.content.get(\"downloadUrl\"))\n        for attachment in attachments\n    ]\n    file_bytes_list = await asyncio.gather(*download_coros)\n    file_refs = []\n    for idx, file_bytes in enumerate(file_bytes_list):\n        if file_bytes:\n            name = attachments[idx].name\n            mime_type = filetype.guess_mime(file_bytes) or \"application/octet-stream\"\n            file_ref = await session.persist_file(\n                name=name, mime=mime_type, content=file_bytes\n            )\n            file_refs.append(file_ref)\n\n    files_dicts = [\n        session.files[file[\"id\"]] for file in file_refs if file[\"id\"] in session.files\n    ]\n\n    elements = [\n        Element.from_dict(\n            {\n                \"id\": file[\"id\"],\n                \"name\": file[\"name\"],\n                \"path\": str(file[\"path\"]),\n                \"chainlitKey\": file[\"id\"],\n                \"display\": \"inline\",\n                \"type\": Element.infer_type_from_mime(file[\"type\"]),\n            }\n        )\n        for file in files_dicts\n    ]\n\n    return elements\n\n\ndef clean_content(activity: Activity):\n    return activity.text.strip()\n\n\nasync def process_teams_message(\n    turn_context: TurnContext,\n    thread_name: str,\n):\n    user = await get_user(turn_context.activity.from_property)\n\n    thread_id = str(\n        uuid.uuid5(\n            uuid.NAMESPACE_DNS,\n            str(\n                turn_context.activity.conversation.id\n                + datetime.today().strftime(\"%Y-%m-%d\")\n            ),\n        )\n    )\n\n    text = clean_content(turn_context.activity)\n    teams_files = turn_context.activity.attachments\n\n    session_id = str(uuid.uuid4())\n\n    session = HTTPSession(\n        id=session_id,\n        thread_id=thread_id,\n        user=user,\n        client_type=\"teams\",\n    )\n\n    ctx = init_teams_context(\n        session=session,\n        turn_context=turn_context,\n    )\n\n    file_elements = await download_teams_files(session, teams_files)\n\n    if on_chat_start := config.code.on_chat_start:\n        await on_chat_start()\n\n    msg = Message(\n        content=text,\n        elements=file_elements,\n        type=\"user_message\",\n        author=user.metadata.get(\"name\"),\n    )\n\n    await msg.send()\n\n    if on_message := config.code.on_message:\n        await on_message(msg)\n\n    if on_chat_end := config.code.on_chat_end:\n        await on_chat_end()\n\n    if data_layer := get_data_layer():\n        if isinstance(user, PersistedUser):\n            try:\n                await data_layer.update_thread(\n                    thread_id=thread_id,\n                    name=thread_name,\n                    metadata=ctx.session.to_persistable(),\n                    user_id=user.id,\n                )\n            except Exception as e:\n                logger.error(f\"Error updating thread: {e}\")\n\n    await ctx.session.delete()\n\n\nasync def handle_message(turn_context: TurnContext):\n    if turn_context.activity.type == ActivityTypes.message:\n        if (\n            turn_context.activity.text == \"like\"\n            or turn_context.activity.text == \"dislike\"\n        ):\n            feedback_value: Literal[0, 1] = (\n                0 if turn_context.activity.text == \"dislike\" else 1\n            )\n            step_id = turn_context.activity.value.get(\"step_id\")\n            if data_layer := get_data_layer():\n                await data_layer.upsert_feedback(\n                    Feedback(forId=step_id, value=feedback_value)\n                )\n            updated_text = \"👍\" if turn_context.activity.text == \"like\" else \"👎\"\n            # Update the existing message to remove the buttons\n            updated_message = Activity(\n                type=ActivityTypes.message,\n                id=turn_context.activity.reply_to_id,\n                text=updated_text,\n                attachments=[],\n            )\n            await turn_context.update_activity(updated_message)\n        else:\n            # Send typing activity\n            typing_activity = Activity(\n                type=ActivityTypes.typing,\n                from_property=turn_context.activity.recipient,\n                recipient=turn_context.activity.from_property,\n                conversation=turn_context.activity.conversation,\n            )\n            await turn_context.send_activity(typing_activity)\n            thread_name = f\"{turn_context.activity.from_property.name} Teams DM {datetime.today().strftime('%Y-%m-%d')}\"\n            await process_teams_message(turn_context, thread_name)\n\n\nasync def on_turn(turn_context: TurnContext):\n    await handle_message(turn_context)\n\n\n# Create the main bot class\nclass TeamsBot:\n    async def on_turn(self, turn_context: TurnContext):\n        await on_turn(turn_context)\n\n\n# Create the bot instance\nbot = TeamsBot()\n"
  },
  {
    "path": "backend/chainlit/translations/ar-SA.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"إلغاء\",\n      \"confirm\": \"تأكيد\",\n      \"continue\": \"متابعة\",\n      \"goBack\": \"رجوع\",\n      \"reset\": \"إعادة تعيين\",\n      \"submit\": \"إرسال\"\n    },\n    \"status\": {\n      \"loading\": \"جاري التحميل...\",\n      \"error\": {\n        \"default\": \"حدث خطأ\",\n        \"serverConnection\": \"تعذر الاتصال بالخادم\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"قم بتسجيل الدخول للوصول إلى التطبيق\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"البريد الإلكتروني\",\n          \"required\": \"البريد الإلكتروني حقل إلزامي\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"كلمة المرور\",\n          \"required\": \"كلمة المرور حقل إلزامي\"\n        },\n        \"actions\": {\n          \"signin\": \"تسجيل الدخول\"\n        },\n        \"alternativeText\": {\n          \"or\": \"أو\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"تعذر تسجيل الدخول\",\n        \"signin\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"oauthSignin\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"redirectUriMismatch\": \"عنوان URI لإعادة التوجيه لا يتطابق مع تكوين تطبيق OAuth\",\n        \"oauthCallback\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"oauthCreateAccount\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"emailCreateAccount\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"callback\": \"حاول تسجيل الدخول بحساب آخر\",\n        \"oauthAccountNotLinked\": \"لتأكيد هويتك، قم بتسجيل الدخول بنفس الحساب الذي استخدمته في الأصل\",\n        \"emailSignin\": \"تعذر إرسال البريد الإلكتروني\",\n        \"emailVerify\": \"يرجى التحقق من بريدك الإلكتروني، تم إرسال بريد إلكتروني جديد\",\n        \"credentialsSignin\": \"فشل تسجيل الدخول. تحقق من صحة المعلومات المقدمة\",\n        \"sessionRequired\": \"يرجى تسجيل الدخول للوصول إلى هذه الصفحة\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"متابعة مع {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"اكتب رسالتك هنا...\",\n      \"actions\": {\n        \"send\": \"إرسال الرسالة\",\n        \"stop\": \"إيقاف المهمة\",\n        \"attachFiles\": \"إرفاق ملفات\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"استخدام رسالة مفضلة\",\n      \"headline\": \"الرسائل المفضلة\",\n      \"empty\": {\n        \"title\": \"لا توجد رسائل محفوظة بعد\",\n        \"description\": \"ابدأ بإرسال رسالة وقم بتمييزها بنجمة أو ميّز رسالة من محادثاتك السابقة\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"أدوات\",\n      \"changeTool\": \"تغيير الأداة\",\n      \"availableTools\": \"الأدوات المتاحة\"\n    },\n    \"speech\": {\n      \"start\": \"بدء التسجيل\",\n      \"stop\": \"إيقاف التسجيل\",\n      \"connecting\": \"جاري الاتصال\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"اسحب وأفلت الملفات هنا\",\n      \"browse\": \"تصفح الملفات\",\n      \"sizeLimit\": \"الحد الأقصى:\",\n      \"errors\": {\n        \"failed\": \"فشل التحميل\",\n        \"cancelled\": \"تم إلغاء تحميل\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"إلغاء التحميل\",\n        \"removeAttachment\": \"إزالة المرفق\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"يستخدم\",\n        \"used\": \"مستخدم\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"نسخ إلى الحافظة\",\n          \"success\": \"تم النسخ!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"مفيد\",\n        \"negative\": \"غير مفيد\",\n        \"edit\": \"تعديل التعليق\",\n        \"dialog\": {\n          \"title\": \"إضافة تعليق\",\n          \"submit\": \"إرسال التعليق\",\n          \"yourFeedback\": \"رأيك...\"\n        },\n        \"status\": {\n          \"updating\": \"جاري التحديث\",\n          \"updated\": \"تم تحديث التعليق\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"المدخلات الأخيرة\",\n      \"empty\": \"فارغ تماماً...\",\n      \"show\": \"عرض السجل\"\n    },\n    \"settings\": {\n      \"title\": \"لوحة الإعدادات\",\n      \"customize\": \"خصص إعدادات المحادثة هنا\"\n    },\n    \"watermark\": \"قد تخطئ نماذج الذكاء الاصطناعي. تحقق من المعلومات المهمة.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"المحادثات السابقة\",\n      \"filters\": {\n        \"search\": \"بحث\",\n        \"placeholder\": \"البحث في المحادثات...\"\n      },\n      \"timeframes\": {\n        \"today\": \"اليوم\",\n        \"yesterday\": \"أمس\",\n        \"previous7days\": \"آخر 7 أيام\",\n        \"previous30days\": \"آخر 30 يوماً\"\n      },\n      \"empty\": \"لم يتم العثور على محادثات\",\n      \"actions\": {\n        \"close\": \"إغلاق الشريط الجانبي\",\n        \"open\": \"فتح الشريط الجانبي\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"محادثة بدون عنوان\",\n      \"menu\": {\n          \"rename\": \"إعادة تسمية\",\n          \"share\": \"مشاركة\",\n          \"delete\": \"حذف\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"مشاركة رابط المحادثة\",\n          \"button\": \"مشاركة\",\n          \"status\": {\n            \"copied\": \"تم نسخ الرابط\",\n            \"created\": \"تم إنشاء رابط المشاركة!\",\n            \"unshared\": \"تم تعطيل المشاركة لهذه المحادثة\"\n          },\n          \"error\": {\n            \"create\": \"فشل إنشاء رابط المشاركة\",\n            \"unshare\": \"فشل تعطيل مشاركة المحادثة\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"تأكيد الحذف\",\n          \"description\": \"سيؤدي هذا إلى حذف المحادثة مع رسائلها وعناصرها. لا يمكن التراجع عن هذا الإجراء\",\n          \"success\": \"تم حذف المحادثة\",\n          \"inProgress\": \"جاري حذف المحادثة\"\n        },\n        \"rename\": {\n          \"title\": \"إعادة تسمية المحادثة\",\n          \"description\": \"أدخل اسماً جديداً لهذه المحادثة\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"الاسم\",\n              \"placeholder\": \"أدخل الاسم الجديد\"\n            }\n          },\n          \"success\": \"تمت إعادة تسمية المحادثة!\",\n          \"inProgress\": \"جاري إعادة تسمية المحادثة\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"محادثة\",\n      \"readme\": \"اقرأني\",\n      \"theme\": {\n        \"light\": \"السمة الفاتحة\",\n        \"dark\": \"السمة الداكنة\",\n        \"system\": \"متابعة النظام\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"محادثة جديدة\",\n      \"dialog\": {\n        \"title\": \"إنشاء محادثة جديدة\",\n        \"description\": \"سيؤدي هذا إلى مسح سجل المحادثة الحالي. هل أنت متأكد من أنك تريد المتابعة؟\",\n        \"tooltip\": \"محادثة جديدة\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"الإعدادات\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"مفاتيح API\",\n        \"logout\": \"تسجيل الخروج\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"مفاتيح API المطلوبة\",\n    \"description\": \"لاستخدام هذا التطبيق، مفاتيح API التالية مطلوبة. يتم تخزين المفاتيح في التخزين المحلي لجهازك.\",\n    \"success\": {\n      \"saved\": \"تم الحفظ بنجاح\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"معلومات\",\n    \"note\": \"ملاحظة\",\n    \"tip\": \"نصيحة\",\n    \"important\": \"مهم\",\n    \"warning\": \"تحذير\",\n    \"caution\": \"تنبيه\",\n    \"debug\": \"تصحيح\",\n    \"example\": \"مثال\",\n    \"success\": \"نجاح\",\n    \"help\": \"مساعدة\",\n    \"idea\": \"فكرة\",\n    \"pending\": \"قيد الانتظار\",\n    \"security\": \"أمان\",\n    \"beta\": \"تجريبي\",\n    \"best-practice\": \"أفضل ممارسة\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"اختر...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"اختر تاريخاً\",\n        \"range\": \"اختر نطاقاً من التواريخ\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/bn.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"বাতিল করুন\",\n      \"confirm\": \"নিশ্চিত করুন\",\n      \"continue\": \"চালিয়ে যান\",\n      \"goBack\": \"পিছনে যান\",\n      \"reset\": \"রিসেট করুন\",\n      \"submit\": \"জমা দিন\"\n    },\n    \"status\": {\n      \"loading\": \"লোড হচ্ছে...\",\n      \"error\": {\n        \"default\": \"একটি ত্রুটি ঘটেছে\",\n        \"serverConnection\": \"সার্ভারের সাথে সংযোগ করা যাচ্ছে না\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"অ্যাপ্লিকেশন ব্যবহার করতে লগইন করুন\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ইমেইল ঠিকানা\",\n          \"required\": \"ইমেইল একটি আবশ্যক ক্ষেত্র\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"পাসওয়ার্ড\",\n          \"required\": \"পাসওয়ার্ড একটি আবশ্যক ক্ষেত্র\"\n        },\n        \"actions\": {\n          \"signin\": \"সাইন ইন করুন\"\n        },\n        \"alternativeText\": {\n          \"or\": \"অথবা\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"সাইন ইন করা সম্ভব হচ্ছে না\",\n        \"signin\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"oauthSignin\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"redirectUriMismatch\": \"রিডাইরেক্ট URI ওআথ অ্যাপ কনফিগারেশনের সাথে মিলছে না\",\n        \"oauthCallback\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"oauthCreateAccount\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"emailCreateAccount\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"callback\": \"অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন\",\n        \"oauthAccountNotLinked\": \"আপনার পরিচয় নিশ্চিত করতে, আপনি যে অ্যাকাউন্টটি মূলত ব্যবহার করেছিলেন সেটি দিয়ে সাইন ইন করুন\",\n        \"emailSignin\": \"ইমেইল পাঠানো যায়নি\",\n        \"emailVerify\": \"অনুগ্রহ করে আপনার ইমেইল যাচাই করুন, একটি নতুন ইমেইল পাঠানো হয়েছে\",\n        \"credentialsSignin\": \"সাইন ইন ব্যর্থ হয়েছে। আপনার দেওয়া তথ্য সঠিক কিনা যাচাই করুন\",\n        \"sessionRequired\": \"এই পৃষ্ঠা দেখতে অনুগ্রহ করে সাইন ইন করুন\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} দিয়ে চালিয়ে যান\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"আপনার বার্তা এখানে টাইপ করুন...\",\n      \"actions\": {\n        \"send\": \"বার্তা পাঠান\",\n        \"stop\": \"কাজ বন্ধ করুন\",\n        \"attachFiles\": \"ফাইল সংযুক্ত করুন\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"রেকর্ডিং শুরু করুন\",\n      \"stop\": \"রেকর্ডিং বন্ধ করুন\",\n      \"connecting\": \"সংযোগ করা হচ্ছে\"\n    },\n    \"favorites\": {\n      \"use\": \"একটি পছন্দের মেসেজ ব্যবহার করুন\",\n      \"headline\": \"পছন্দের মেসেজ\",\n      \"remove\": \"পছন্দ বাতিল করুন\",\n      \"empty\": {\n        \"title\": \"এখনও কোনো প্রম্পট সংরক্ষিত নেই\",\n        \"description\": \"একটি প্রম্পট পাঠিয়ে এবং তাতে তারকা চিহ্ন দিয়ে শুরু করুন বা আগের চ্যাট থেকে একটি প্রম্পটে তারকা চিহ্ন দিন\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"টুলস\",\n      \"changeTool\": \"টুল পরিবর্তন করুন\",\n      \"availableTools\": \"উপলব্ধ টুলস\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"এখানে ফাইল টেনে আনুন\",\n      \"browse\": \"ফাইল ব্রাউজ করুন\",\n      \"sizeLimit\": \"সীমা:\",\n      \"errors\": {\n        \"failed\": \"আপলোড ব্যর্থ হয়েছে\",\n        \"cancelled\": \"আপলোড বাতিল করা হয়েছে\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"আপলোড বাতিল করুন\",\n        \"removeAttachment\": \"সংযুক্তি মুছে ফেলুন\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"ব্যবহার করছে\",\n        \"used\": \"ব্যবহৃত\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"ক্লিপবোর্ডে কপি করুন\",\n          \"success\": \"কপি করা হয়েছে!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"সহায়ক\",\n        \"negative\": \"সহায়ক নয়\",\n        \"edit\": \"প্রতিক্রিয়া সম্পাদনা করুন\",\n        \"dialog\": {\n          \"title\": \"মন্তব্য যোগ করুন\",\n          \"submit\": \"প্রতিক্রিয়া জমা দিন\",\n          \"yourFeedback\": \"আপনার প্রতিক্রিয়া...\"\n        },\n        \"status\": {\n          \"updating\": \"হালনাগাদ করা হচ্ছে\",\n          \"updated\": \"প্রতিক্রিয়া হালনাগাদ করা হয়েছে\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"সর্বশেষ ইনপুট\",\n      \"empty\": \"কোনো তথ্য নেই...\",\n      \"show\": \"ইতিহাস দেখুন\"\n    },\n    \"settings\": {\n      \"title\": \"সেটিংস প্যানেল\",\n      \"customize\": \"এখানে আপনার চ্যাট সেটিংস কাস্টমাইজ করুন\"\n    },\n    \"watermark\": \"এলএলএম ভুল করতে পারে। গুরুত্বপূর্ণ তথ্য যাচাই করার কথা বিবেচনা করুন।\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"পূর্ববর্তী চ্যাট\",\n      \"filters\": {\n        \"search\": \"অনুসন্ধান\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"আজ\",\n        \"yesterday\": \"গতকাল\",\n        \"previous7days\": \"গত ৭ দিন\",\n        \"previous30days\": \"গত ৩০ দিন\"\n      },\n      \"empty\": \"কোনো থ্রেড পাওয়া যায়নি\",\n      \"actions\": {\n        \"close\": \"সাইডবার বন্ধ করুন\",\n        \"open\": \"সাইডবার খুলুন\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"শিরোনামহীন আলোচনা\",\n      \"menu\": {\n        \"rename\": \"পুনঃনামকরণ\",\n        \"share\": \"শেয়ার\",\n        \"delete\": \"Delete\"\n      },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"চ্যাটের লিঙ্ক শেয়ার করুন\",\n          \"button\": \"শেয়ার\",\n          \"status\": {\n            \"copied\": \"লিঙ্ক কপি করা হয়েছে\",\n            \"created\": \"শেয়ার লিঙ্ক তৈরি হয়েছে!\",\n            \"unshared\": \"এই থ্রেডের জন্য শেয়ারিং অক্ষম করা হয়েছে\"\n          },\n          \"error\": {\n            \"create\": \"শেয়ার লিঙ্ক তৈরি করতে ব্যর্থ\",\n            \"unshare\": \"থ্রেডের শেয়ারিং বন্ধ করতে ব্যর্থ\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"মুছে ফেলা নিশ্চিত করুন\",\n          \"description\": \"এটি থ্রেড এবং এর বার্তা ও উপাদানগুলি মুছে ফেলবে। এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না\",\n          \"success\": \"চ্যাট মুছে ফেলা হয়েছে\",\n          \"inProgress\": \"চ্যাট মুছে ফেলা হচ্ছে\"\n        },\n        \"rename\": {\n          \"title\": \"থ্রেডের নাম পরিবর্তন করুন\",\n          \"description\": \"এই থ্রেডের জন্য একটি নতুন নাম দিন\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"নাম\",\n              \"placeholder\": \"নতুন নাম লিখুন\"\n            }\n          },\n          \"success\": \"থ্রেডের নাম পরিবর্তন করা হয়েছে!\",\n          \"inProgress\": \"থ্রেডের নাম পরিবর্তন করা হচ্ছে\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"চ্যাট\",\n      \"readme\": \"রিডমি\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"নতুন চ্যাট\",\n      \"dialog\": {\n        \"title\": \"নতুন চ্যাট তৈরি করুন\",\n        \"description\": \"এটি আপনার বর্তমান চ্যাট ইতিহাস মুছে ফেলবে। আপনি কি চালিয়ে যেতে চান?\",\n        \"tooltip\": \"নতুন চ্যাট\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"সেটিংস\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"এপিআই কী\",\n        \"logout\": \"লগআউট\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"প্রয়োজনীয় এপিআই কী\",\n    \"description\": \"এই অ্যাপ্লিকেশন ব্যবহার করতে নিম্নলিখিত এপিআই কী প্রয়োজন। কীগুলি আপনার ডিভাইসের লোকাল স্টোরেজে সংরক্ষিত থাকে।\",\n    \"success\": {\n      \"saved\": \"সফলভাবে সংরক্ষিত হয়েছে\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"বেছে নিন...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"একটি তারিখ বেছে নিন\",\n        \"range\": \"তারিখের পরিসীমা বেছে নিন\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/da-DK.json",
    "content": "{\n    \"common\": {\n        \"actions\": {\n            \"cancel\": \"Annuller\",\n            \"confirm\": \"Bekræft\",\n            \"continue\": \"Fortsæt\",\n            \"goBack\": \"Gå tilbage\",\n            \"reset\": \"Nulstil\",\n            \"submit\": \"Indsend\"\n        },\n        \"status\": {\n            \"loading\": \"Indlæser...\",\n            \"error\": {\n                \"default\": \"Der opstod en fejl\",\n                \"serverConnection\": \"Kunne ikke nå serveren\"\n            }\n        }\n    },\n    \"auth\": {\n        \"login\": {\n            \"title\": \"Log ind for at få adgang til appen\",\n            \"form\": {\n                \"email\": {\n                    \"label\": \"E-mailadresse\",\n                    \"required\": \"e-mail er et påkrævet felt\",\n                    \"placeholder\": \"me@example.com\"\n                },\n                \"password\": {\n                    \"label\": \"Adgangskode\",\n                    \"required\": \"adgangskode er et påkrævet felt\"\n                },\n                \"actions\": {\n                    \"signin\": \"Log ind\"\n                },\n                \"alternativeText\": {\n                    \"or\": \"ELLER\"\n                }\n            },\n            \"errors\": {\n                \"default\": \"Kunne ikke logge ind\",\n                \"signin\": \"Prøv at logge ind med en anden konto\",\n                \"oauthSignin\": \"Prøv at logge ind med en anden konto\",\n                \"redirectUriMismatch\": \"Omdirigerings-URI'en matcher ikke oauth-app konfigurationen\",\n                \"oauthCallback\": \"Prøv at logge ind med en anden konto\",\n                \"oauthCreateAccount\": \"Prøv at logge ind med en anden konto\",\n                \"emailCreateAccount\": \"Prøv at logge ind med en anden konto\",\n                \"callback\": \"Prøv at logge ind med en anden konto\",\n                \"oauthAccountNotLinked\": \"For at bekræfte din identitet, log ind med samme konto, som du oprindeligt brugte\",\n                \"emailSignin\": \"E-mailen kunne ikke sendes\",\n                \"emailVerify\": \"Bekræft venligst din e-mail, en ny e-mail er blevet sendt\",\n                \"credentialsSignin\": \"Login mislykkedes. Kontroller at de angivne oplysninger er korrekte\",\n                \"sessionRequired\": \"Log venligst ind for at få adgang til denne side\"\n            }\n        },\n        \"provider\": {\n            \"continue\": \"Fortsæt med {{provider}}\"\n        }\n    },\n    \"chat\": {\n        \"input\": {\n            \"placeholder\": \"Skriv din besked her...\",\n            \"actions\": {\n                \"send\": \"Send besked\",\n                \"stop\": \"Stop opgave\",\n                \"attachFiles\": \"Vedhæft filer\"\n            }\n        },\n        \"favorites\": {\n            \"use\": \"Brug en favorit besked\",\n            \"headline\": \"Favorit beskeder\",\n            \"empty\": {\n                \"title\": \"Ingen gemte prompts endnu\",\n                \"description\": \"Start med at sende en prompt og markere den med en stjerne, eller vælg en prompt fra tidligere samtaler\"\n            }\n        },\n        \"commands\": {\n            \"button\": \"Værktøjer\",\n            \"changeTool\": \"Skift værktøj\",\n            \"availableTools\": \"Tilgængelige værktøjer\"\n        },\n        \"speech\": {\n            \"start\": \"Start optagelse\",\n            \"stop\": \"Stop optagelse\",\n            \"connecting\": \"Forbinder\"\n        },\n        \"fileUpload\": {\n            \"dragDrop\": \"Træk og slip filer her\",\n            \"browse\": \"Gennemse filer\",\n            \"sizeLimit\": \"Grænse:\",\n            \"errors\": {\n                \"failed\": \"Upload mislykkedes\",\n                \"cancelled\": \"Annullerede upload af\"\n            },\n            \"actions\": {\n                \"cancelUpload\": \"Annullere upload\",\n                \"removeAttachment\": \"Fjern vedhæftning\"\n            }\n        },\n        \"messages\": {\n            \"status\": {\n                \"using\": \"Bruger\",\n                \"used\": \"Brugte\"\n            },\n            \"actions\": {\n                \"copy\": {\n                    \"button\": \"Kopier til udklipsholder\",\n                    \"success\": \"Kopieret!\"\n                }\n            },\n            \"feedback\": {\n                \"positive\": \"Hjælpsom\",\n                \"negative\": \"Ikke hjælpsom\",\n                \"edit\": \"Rediger feedback\",\n                \"dialog\": {\n                    \"title\": \"Tilføj en kommentar\",\n                    \"submit\": \"Indsend feedback\",\n                    \"yourFeedback\": \"Din feedback...\"\n                },\n                \"status\": {\n                    \"updating\": \"Opdaterer\",\n                    \"updated\": \"Feedback opdateret\"\n                }\n            }\n        },\n        \"history\": {\n            \"title\": \"Seneste input\",\n            \"empty\": \"Så tomt...\",\n            \"show\": \"Vis historik\"\n        },\n        \"settings\": {\n            \"title\": \"Indstillingspanel\",\n            \"customize\": \"Tilpas dine chatindstillinger her\"\n        },\n        \"watermark\": \"Bygget med\"\n    },\n    \"threadHistory\": {\n        \"sidebar\": {\n            \"title\": \"Tidligere samtaler\",\n            \"filters\": {\n                \"search\": \"Søg\",\n                \"placeholder\": \"Søg i samtaler...\"\n            },\n            \"timeframes\": {\n                \"today\": \"I dag\",\n                \"yesterday\": \"I går\",\n                \"previous7days\": \"Seneste 7 dage\",\n                \"previous30days\": \"Seneste 30 dage\"\n            },\n            \"empty\": \"Ingen tråde fundet\",\n            \"actions\": {\n                \"close\": \"Luk sidepanel\",\n                \"open\": \"Åbn sidepanel\"\n            }\n        },\n        \"thread\": {\n            \"untitled\": \"Unavngivet samtale\",\n            \"menu\": {\n                \"rename\": \"Omdøb\",\n                \"share\": \"Del\",\n                \"delete\": \"Slet\"\n            },\n            \"actions\": {\n                \"share\": {\n                    \"title\": \"Del link til chat\",\n                    \"button\": \"Del\",\n                    \"status\": {\n                        \"copied\": \"Link kopieret\",\n                        \"created\": \"Delingslink oprettet!\",\n                        \"unshared\": \"Deling deaktiveret for denne tråd\"\n                    },\n                    \"error\": {\n                        \"create\": \"Kunne ikke oprette delingslink\",\n                        \"unshare\": \"Kunne ikke fjerne deling af tråd\"\n                    }\n                },\n                \"delete\": {\n                    \"title\": \"Bekræft sletning\",\n                    \"description\": \"Dette vil slette tråden samt dens beskeder og elementer. Denne handling kan ikke fortrydes\",\n                    \"success\": \"Chat slettet\",\n                    \"inProgress\": \"Sletter chat\"\n                },\n                \"rename\": {\n                    \"title\": \"Omdøb tråd\",\n                    \"description\": \"Indtast et nyt navn til denne tråd\",\n                    \"form\": {\n                        \"name\": {\n                            \"label\": \"Navn\",\n                            \"placeholder\": \"Indtast nyt navn\"\n                        }\n                    },\n                    \"success\": \"Tråd omdøbt!\",\n                    \"inProgress\": \"Omdøber tråd\"\n                }\n            }\n        }\n    },\n    \"navigation\": {\n        \"header\": {\n            \"chat\": \"Chat\",\n            \"readme\": \"📖\",\n            \"theme\": {\n                \"light\": \"Lyst tema\",\n                \"dark\": \"Mørkt tema\",\n                \"system\": \"Følg system\"\n            }\n        },\n        \"newChat\": {\n            \"button\": \"Ny chat\",\n            \"dialog\": {\n                \"title\": \"Opret ny chat\",\n                \"description\": \"Dette vil rydde din nuværende chathistorik. Er du sikker på, at du vil fortsætte?\",\n                \"tooltip\": \"Ny chat\"\n            }\n        },\n        \"user\": {\n            \"menu\": {\n                \"settings\": \"Indstillinger\",\n                \"settingsKey\": \"S\",\n                \"apiKeys\": \"API-nøgler\",\n                \"logout\": \"Log ud\"\n            }\n        }\n    },\n    \"apiKeys\": {\n        \"title\": \"Påkrævede API-nøgler\",\n        \"description\": \"For at bruge denne app kræves følgende API-nøgler. Nøglerne gemmes på din enheds lokale lager.\",\n        \"success\": {\n            \"saved\": \"Gemt succesfuldt\"\n        }\n    },\n    \"alerts\": {\n        \"info\": \"Info\",\n        \"note\": \"Bemærk\",\n        \"tip\": \"Tip\",\n        \"important\": \"Vigtigt\",\n        \"warning\": \"Advarsel\",\n        \"caution\": \"Forsigtig\",\n        \"debug\": \"Fejlfinding\",\n        \"example\": \"Eksempel\",\n        \"success\": \"Succes\",\n        \"help\": \"Hjælp\",\n        \"idea\": \"Idé\",\n        \"pending\": \"Afventer\",\n        \"security\": \"Sikkerhed\",\n        \"beta\": \"Beta\",\n        \"best-practice\": \"Bedste praksis\"\n    },\n    \"components\": {\n        \"MultiSelectInput\": {\n            \"placeholder\": \"Vælg...\"\n        },\n        \"DatePickerInput\": {\n            \"placeholder\": {\n                \"single\": \"Vælg en dato\",\n                \"range\": \"Vælg et datointerval\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/de-DE.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"Abbrechen\",\n      \"confirm\": \"Bestätigen\",\n      \"continue\": \"Fortfahren\",\n      \"goBack\": \"Zurück\",\n      \"reset\": \"Zurücksetzen\",\n      \"submit\": \"Absenden\"\n    },\n    \"status\": {\n      \"loading\": \"Lädt...\",\n      \"error\": {\n        \"default\": \"Ein Fehler ist aufgetreten\",\n        \"serverConnection\": \"Server konnte nicht erreicht werden\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"Melde dich an, um auf die App zuzugreifen\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"E-Mail Adresse\",\n          \"required\": \"E-Mail Adresse ist ein Pflichtfeld\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"Passwort\",\n          \"required\": \"Passwort ist ein Pflichtfeld\"\n        },\n        \"actions\": {\n          \"signin\": \"Anmelden\"\n        },\n        \"alternativeText\": {\n          \"or\": \"ODER\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"Anmeldung fehlgeschlagen\",\n        \"signin\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"oauthSignin\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"redirectUriMismatch\": \"Der Redirect-URI stimmt nicht mit der Konfiguration der Oauth-Anwendung überein\",\n        \"oauthCallback\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"oauthCreateAccount\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"emailCreateAccount\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"callback\": \"Versuche dich mit einem anderen Konto anzumelden\",\n        \"oauthAccountNotLinked\": \"Um die Identität zu bestätigen, melde dich mit demselben Konto an, das du ursprünglich verwendet hast\",\n        \"emailSignin\": \"Die E-Mail konnte nicht gesendet werden\",\n        \"emailVerify\": \"Es wurde eine neue E-Mail versandt. Bitte überprüfe dein E-Mail Postfach\",\n        \"credentialsSignin\": \"Anmeldung fehlgeschlagen. Überprüfe, ob die angegebenen Benutzerdaten korrekt sind\",\n        \"sessionRequired\": \"Bitte melde dich an, um auf diese Seite zuzugreifen\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"Fortfahren mit {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"Nachricht eingeben...\",\n      \"actions\": {\n        \"send\": \"Nachricht senden\",\n        \"stop\": \"Aufgabe stoppen\",\n        \"attachFiles\": \"Dateien anhängen\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"Eine favorisierte Nachricht verwenden\",\n      \"headline\": \"Favorisierte Nachrichten\",\n      \"remove\": \"Favorit entfernen\",\n      \"empty\": {\n        \"title\": \"Noch keine Prompts gespeichert\",\n        \"description\": \"Beginne, indem du einen Prompt sendest und mit einem Stern markierst oder markiere einen Prompt aus vorherigen Chats\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"Tools\",\n      \"changeTool\": \"Tool wechseln\",\n      \"availableTools\": \"Verfügbare Tools\"\n    },\n    \"speech\": {\n      \"start\": \"Aufnahme starten\",\n      \"stop\": \"Aufnahme stoppen\",\n      \"connecting\": \"Verbinde\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"Ziehe deine Dateien hierher\",\n      \"browse\": \"Dateien durchsuchen\",\n      \"sizeLimit\": \"Limit:\",\n      \"errors\": {\n        \"failed\": \"Hochladen fehlgeschlagen\",\n        \"cancelled\": \"Abbruch des hochladens von\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"Upload abbrechen\",\n        \"removeAttachment\": \"Anhang entfernen\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"Verwendet\",\n        \"used\": \"Verwendete\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"In Zwischenablage kopieren\",\n          \"success\": \"Kopiert!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"Hilfreich\",\n        \"negative\": \"Nicht hilfreich\",\n        \"edit\": \"Feedback editieren\",\n        \"dialog\": {\n          \"title\": \"Füge einen Kommentar hinzu\",\n          \"submit\": \"Feedback absenden\",\n          \"yourFeedback\": \"Dein Feedback...\"\n        },\n        \"status\": {\n          \"updating\": \"Aktualisiert\",\n          \"updated\": \"Feedback aktualisiert\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"Vergangene Eingaben\",\n      \"empty\": \"Leer...\",\n      \"show\": \"Historie anzeigen\"\n    },\n    \"settings\": {\n      \"title\": \"Einstellungen\",\n      \"customize\": \"Passe die Chat Einstellungen hier an\"\n    },\n    \"watermark\": \"LLMs können Fehler machen. Überprüfe bitte stets die Inhalte.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"Vergangene Chats\",\n      \"filters\": {\n        \"search\": \"Suche\",\n        \"placeholder\": \"Suche konversationen...\"\n      },\n      \"timeframes\": {\n        \"today\": \"Heute\",\n        \"yesterday\": \"Gestern\",\n        \"previous7days\": \"Vor 7 Tagen\",\n        \"previous30days\": \"Vor 30 Tagen\"\n      },\n      \"empty\": \"Kein Chat gefunden\",\n      \"actions\": {\n        \"close\": \"Seitenleiste schließen\",\n        \"open\": \"Seitenleiste öffnen\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"Unbenannter Thread\",\n      \"menu\": {\n          \"rename\": \"Umbenennen\",\n          \"share\": \"Teilen\",\n          \"delete\": \"Löschen\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"Thread löschen bestätigen\",\n          \"button\": \"Teilen\",\n          \"status\": {\n            \"copied\": \"Link kopiert\",\n            \"created\": \"Freigabelink erstellt!\",\n            \"unshared\": \"Teilen ist für diesen Thread deaktiviert\"\n          },\n          \"error\": {\n            \"create\": \"Fehler beim Erstellen des Freigabelinks\",\n            \"unshare\": \"Freigabe des Threads konnte nicht aufgehoben werden\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"Löschen bestätigen\",\n          \"description\": \"Dies wird den Thread sowie seine Nachrichten und Elemente löschen. Dies kann nicht rückgängig gemacht werden\",\n          \"success\": \"Chat gelöscht\",\n          \"inProgress\": \"Chat wird gelöscht\"\n        },\n        \"rename\": {\n          \"title\": \"Thread umbenennen\",\n          \"description\": \"Gebe einen neuen Namen für den Thread ein\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"Name\",\n              \"placeholder\": \"Neuen Namen eingeben\"\n            }\n          },\n          \"success\": \"Thread umbenannt!\",\n          \"inProgress\": \"Thread wird umbenannt\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"Chat\",\n      \"readme\": \"Anleitung\",\n      \"theme\": {\n        \"light\": \"Helles Design\",\n        \"dark\": \"Dunkles Design\",\n        \"system\": \"System Design\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"Neuer Chat\",\n      \"dialog\": {\n        \"title\": \"Möchtest du einen neuen Chat erstellen?\",\n        \"description\": \"Es werden die aktuellen Nachrichten gelöscht und ein neuer Chat geöffnet.\",\n        \"tooltip\": \"Neuer Chat\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"Einstellungen\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API Schlüssel\",\n        \"logout\": \"Abmelden\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"Benötigte API Schlüssel\",\n    \"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.\",\n    \"success\": {\n      \"saved\": \"Erfolgreich gespeichert\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Hinweis\",\n    \"tip\": \"Tipp\",\n    \"important\": \"Wichtig\",\n    \"warning\": \"Warnung\",\n    \"caution\": \"Vorsicht\",\n    \"debug\": \"Debug\",\n    \"example\": \"Beispiel\",\n    \"success\": \"Erfolg\",\n    \"help\": \"Hilfe\",\n    \"idea\": \"Idee\",\n    \"pending\": \"Ausstehend\",\n    \"security\": \"Sicherheit\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Bewährte Praxis\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Wähle aus...\"\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/el-GR.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"Άκυρο\",\n      \"confirm\": \"Επιβεβαίωση\",\n      \"continue\": \"Συνέχεια\",\n      \"goBack\": \"Επιστροφή\",\n      \"reset\": \"Επαναφορά\",\n      \"submit\": \"Υποβολή\"\n    },\n    \"status\": {\n      \"loading\": \"Φόρτωση...\",\n      \"error\": {\n        \"default\": \"Παρουσιάστηκε σφάλμα\",\n        \"serverConnection\": \"Δεν ήταν δυνατή η επικοινωνία με τον διακομιστή\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"Συνδεθείτε για να αποκτήσετε πρόσβαση στην εφαρμογή\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"Διεύθυνση ηλεκτρονικού ταχυδρομείου\",\n          \"required\": \"Το email είναι υποχρεωτικό πεδίο\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"Κωδικός πρόσβασης\",\n          \"required\": \"Ο κωδικός πρόσβασης είναι υποχρεωτικό πεδίο\"\n        },\n        \"actions\": {\n          \"signin\": \"Σύνδεση\"\n        },\n        \"alternativeText\": {\n          \"or\": \"ή\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"Δεν είναι δυνατή η σύνδεση\",\n        \"signin\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"oauthSignin\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"redirectUriMismatch\": \"Ο σύνδεσμος ανακατεύθυνσης δεν ταιριάζει με τη ρύθμιση της αυθεντικοποιήσης της εφαρμογής\",\n        \"oauthCallback\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"oauthCreateAccount\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"emailCreateAccount\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"callback\": \"Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό\",\n        \"oauthAccountNotLinked\": \"Για να επιβεβαιώσετε την ταυτότητά σας, συνδεθείτε με τον ίδιο λογαριασμό που χρησιμοποιήσατε αρχικά\",\n        \"emailSignin\": \"Δεν ήταν δυνατή η αποστολή του email\",\n        \"emailVerify\": \"Παρακαλώ επαληθεύστε την διεύθυνση ηλεκτρονικού ταχυδρομείου σας, ένα νέο email σας έχει σταλεί\",\n        \"credentialsSignin\": \"Η σύνδεση απέτυχε. Ελέγξτε ότι τα στοιχεία που δώσατε είναι σωστά\",\n        \"sessionRequired\": \"Παρακαλώ συνδεθείτε για να αποκτήσετε πρόσβαση σε αυτήν τη σελίδα\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"Συνέχεια με {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"Πληκτρολογήστε το μήνυμά σας εδώ...\",\n      \"actions\": {\n        \"send\": \"Αποστολή μηνύματος\",\n        \"stop\": \"Διακοπή εργασίας\",\n        \"attachFiles\": \"Επισύναψη αρχείων\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"Χρησιμοποιήστε ένα αγαπημένο μήνυμα\",\n      \"headline\": \"Αγαπημένα μηνύματα\",\n      \"remove\": \"Αφαίρεση αγαπημένου\",\n      \"empty\": {\n        \"title\": \"Δεν υπάρχουν αποθηκευμένες προτροπές ακόμα\",\n        \"description\": \"Ξεκινήστε στέλνοντας μια προτροπή και προσθέστε την στα αγαπημένα ή προσθέστε μια προτροπή από προηγούμενες συνομιλίες\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"Εργαλεία\",\n      \"changeTool\": \"Αλλαγή Εργαλείου\",\n      \"availableTools\": \"Διαθέσιμα Εργαλεία\"\n    },\n    \"speech\": {\n      \"start\": \"Έναρξη εγγραφής\",\n      \"stop\": \"Διακοπή εγγραφής\",\n      \"connecting\": \"Σύνδεση\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"Σύρετε αρχεία εδώ\",\n      \"browse\": \"Αναζήτηση αρχείων\",\n      \"sizeLimit\": \"Όριο:\",\n      \"errors\": {\n        \"failed\": \"Η μεταφόρτωση απέτυχε\",\n        \"cancelled\": \"Ακυρώθηκε η μεταφόρτωση του\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"Ακύρωση μεταφόρτωσης\",\n        \"removeAttachment\": \"Αφαίρεση επισύναψης\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"Με τη χρήση\",\n        \"used\": \"Χρησιμοποιήθηκε\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"Αντιγραφή στο πρόχειρο\",\n          \"success\": \"Αντιγράφηκε!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"Χρήσιμος\",\n        \"negative\": \"Μη χρήσιμος\",\n        \"edit\": \"Επεξεργασία σχολίων\",\n        \"dialog\": {\n          \"title\": \"Προσθήκη σχολίου\",\n          \"submit\": \"Υποβολή σχολίων\",\n          \"yourFeedback\": \"Η γνώμη σας\"\n        },\n        \"status\": {\n          \"updating\": \"Ενημερώνεται\",\n          \"updated\": \"Τα σχόλια ενημερώθηκαν\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"Τελευταίες εισαγωγές\",\n      \"empty\": \"Τόσο άδειο...\",\n      \"show\": \"Προβολή ιστορικού\"\n    },\n    \"settings\": {\n      \"title\": \"Πίνακας ρυθμίσεων\",\n      \"customize\": \"Προσαρμογή\"\n    },\n    \"watermark\": \"Τα ΜΓΜ μπορεί να κάνουν λάθη. Ελέγξτε σημαντικές πληροφορίες.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"Παλαιότερες συνομιλίες\",\n      \"filters\": {\n        \"search\": \"Αναζήτηση\",\n        \"placeholder\": \"Αναζήτηση συνομιλιών...\"\n      },\n      \"timeframes\": {\n        \"today\": \"Σήμερα\",\n        \"yesterday\": \"Χθες\",\n        \"previous7days\": \"Προηγούμενες 7 ημέρες\",\n        \"previous30days\": \"Προηγούμενες 30 ημέρες\"\n      },\n      \"empty\": \"Δεν βρέθηκαν νήματα\",\n      \"actions\": {\n        \"close\": \"Κλείσιμο πλαϊνής γραμμής\",\n        \"open\": \"Άνοιγμα πλαϊνής γραμμής\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"Συνομιλία χωρίς τίτλο\",\n      \"menu\": {\n          \"rename\": \"Μετονομασία\",\n          \"share\": \"Κοινοποίηση\",\n          \"delete\": \"Διαγραφή\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"Κοινοποίηση συνδέσμου συνομιλίας\",\n          \"button\": \"Κοινοποίηση\",\n          \"status\": {\n            \"copied\": \"Ο σύνδεσμος αντιγράφηκε\",\n            \"created\": \"Ο σύνδεσμος κοινοποίησης δημιουργήθηκε!\",\n            \"unshared\": \"Η κοινοποίηση απενεργοποιήθηκε για αυτό το νήμα\"\n          },\n          \"error\": {\n            \"create\": \"Αποτυχία δημιουργίας συνδέσμου κοινοποίησης\",\n            \"unshare\": \"Αποτυχία διακοπής κοινοποίησης νήματος\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"Επιβεβαίωση διαγραφής\",\n          \"description\": \"Αυτό θα διαγράψει το νήμα καθώς και τα μηνύματα και τα στοιχεία του. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.\",\n          \"success\": \"Η συνομιλία διαγράφηκε\",\n          \"inProgress\": \"Διαγραφή συνομιλίας\"\n        },\n        \"rename\": {\n          \"title\": \"Μετονομασία Νήματος\",\n          \"description\": \"Εισαγάγετε ένα νέο όνομα για αυτό το νήμα\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"Όνομα\",\n              \"placeholder\": \"Εισαγάγετε νέο όνομα\"\n            }\n          },\n          \"success\": \"Το νήμα μετονομάστηκε!\",\n          \"inProgress\": \"Μετονομασία Νήματος\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"Συνομιλία\",\n      \"readme\": \"Διάβασέ με\",\n      \"theme\": {\n        \"light\": \"Φωτεινό Θέμα\",\n        \"dark\": \"Σκοτεινό θέμα\",\n        \"system\": \"Ακολουθήστε το σύστημα\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"Νέα Συνομιλία\",\n      \"dialog\": {\n        \"title\": \"Δημιουργία Νέας Συνομιλίας\",\n        \"description\": \"Αυτό θα διαγράψει το τρέχον ιστορικό συνομιλίας σας. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;\",\n        \"tooltip\": \"Νέα Συνομιλία\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"Ρυθμίσεις\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"Κλειδιά API\",\n        \"logout\": \"Αποσύνδεση\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"Απαιτούμενα κλειδιά API\",\n    \"description\": \"Για να χρησιμοποιήσετε αυτήν την εφαρμογή, απαιτούνται τα ακόλουθα κλειδιά API. Τα κλειδιά είναι αποθηκευμένα στον τοπικό χώρο αποθήκευσης της συσκευής σας.\",\n    \"success\": {\n      \"saved\": \"Αποθηκεύτηκε με επιτυχία\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Πληροφορίες\",\n    \"note\": \"Σημείωση\",\n    \"tip\": \"Συμβουλή\",\n    \"important\": \"Σημαντικό\",\n    \"warning\": \"Προειδοποίηση\",\n    \"caution\": \"Προσοχή\",\n    \"debug\": \"Εντοπισμός σφαλμάτων\",\n    \"example\": \"Παράδειγμα\",\n    \"success\": \"Επιτυχία\",\n    \"help\": \"Βοήθεια\",\n    \"idea\": \"Ιδέα\",\n    \"pending\": \"Σε εκκρεμότητα\",\n    \"security\": \"Ασφάλεια\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Βέλτιστη Πρακτική\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Επιλέξτε...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"Επιλέξτε ημερομηνία\",\n        \"range\": \"Επιλέξτε εύρος ημερομηνιών\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/en-US.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"Cancel\",\n      \"confirm\": \"Confirm\",\n      \"continue\": \"Continue\",\n      \"goBack\": \"Go Back\",\n      \"reset\": \"Reset\",\n      \"submit\": \"Submit\"\n    },\n    \"status\": {\n      \"loading\": \"Loading...\",\n      \"error\": {\n        \"default\": \"An error occurred\",\n        \"serverConnection\": \"Could not reach the server\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"Login to access the app\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"Email address\",\n          \"required\": \"email is a required field\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"Password\",\n          \"required\": \"password is a required field\"\n        },\n        \"actions\": {\n          \"signin\": \"Sign In\"\n        },\n        \"alternativeText\": {\n          \"or\": \"OR\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"Unable to sign in\",\n        \"signin\": \"Try signing in with a different account\",\n        \"oauthSignin\": \"Try signing in with a different account\",\n        \"redirectUriMismatch\": \"The redirect URI is not matching the oauth app configuration\",\n        \"oauthCallback\": \"Try signing in with a different account\",\n        \"oauthCreateAccount\": \"Try signing in with a different account\",\n        \"emailCreateAccount\": \"Try signing in with a different account\",\n        \"callback\": \"Try signing in with a different account\",\n        \"oauthAccountNotLinked\": \"To confirm your identity, sign in with the same account you used originally\",\n        \"emailSignin\": \"The e-mail could not be sent\",\n        \"emailVerify\": \"Please verify your email, a new email has been sent\",\n        \"credentialsSignin\": \"Sign in failed. Check the details you provided are correct\",\n        \"sessionRequired\": \"Please sign in to access this page\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"Continue with {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"Type your message here...\",\n      \"actions\": {\n        \"send\": \"Send message\",\n        \"stop\": \"Stop Task\",\n        \"attachFiles\": \"Attach files\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"Use a favorite message\",\n      \"headline\": \"Favorite Messages\",\n      \"remove\": \"Remove favorite\",\n      \"empty\": {\n        \"title\": \"No Saved Prompts Yet\",\n        \"description\": \"Start by sending a prompt and star it or star a prompt from previous chats\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"Tools\",\n      \"changeTool\": \"Change Tool\",\n      \"availableTools\": \"Available Tools\"\n    },\n    \"speech\": {\n      \"start\": \"Start recording\",\n      \"stop\": \"Stop recording\",\n      \"connecting\": \"Connecting\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"Drag and drop files here\",\n      \"browse\": \"Browse Files\",\n      \"sizeLimit\": \"Limit:\",\n      \"errors\": {\n        \"failed\": \"Failed to upload\",\n        \"cancelled\": \"Cancelled upload of\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"Cancel upload\",\n        \"removeAttachment\": \"Remove attachment\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"Using\",\n        \"used\": \"Used\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"Copy to clipboard\",\n          \"success\": \"Copied!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"Helpful\",\n        \"negative\": \"Not helpful\",\n        \"edit\": \"Edit feedback\",\n        \"dialog\": {\n          \"title\": \"Add a comment\",\n          \"submit\": \"Submit feedback\",\n          \"yourFeedback\": \"Your feedback...\"\n        },\n        \"status\": {\n          \"updating\": \"Updating\",\n          \"updated\": \"Feedback updated\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"Last Inputs\",\n      \"empty\": \"Such empty...\",\n      \"show\": \"Show history\"\n    },\n    \"settings\": {\n      \"title\": \"Settings panel\",\n      \"customize\": \"Customize your chat settings here\"\n    },\n    \"watermark\": \"LLMs can make mistakes. Check important info.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"Past Chats\",\n      \"filters\": {\n        \"search\": \"Search\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"Today\",\n        \"yesterday\": \"Yesterday\",\n        \"previous7days\": \"Previous 7 days\",\n        \"previous30days\": \"Previous 30 days\"\n      },\n      \"empty\": \"No threads found\",\n      \"actions\": {\n        \"close\": \"Close sidebar\",\n        \"open\": \"Open sidebar\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"Untitled Conversation\",\n      \"menu\": {\n          \"rename\": \"Rename\",\n          \"share\": \"Share\",\n          \"delete\": \"Delete\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"Share link to chat\",\n          \"button\": \"Share\",\n          \"status\": {\n            \"copied\": \"Link copied\",\n            \"created\": \"Share link created!\",\n            \"unshared\": \"Sharing disabled for this thread\"\n          },\n          \"error\": {\n            \"create\": \"Failed to create share link\",\n            \"unshare\": \"Failed to unshare thread\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"Confirm deletion\",\n          \"description\": \"This will delete the thread as well as its messages and elements. This action cannot be undone\",\n          \"success\": \"Chat deleted\",\n          \"inProgress\": \"Deleting chat\"\n        },\n        \"rename\": {\n          \"title\": \"Rename Thread\",\n          \"description\": \"Enter a new name for this thread\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"Name\",\n              \"placeholder\": \"Enter new name\"\n            }\n          },\n          \"success\": \"Thread renamed!\",\n          \"inProgress\": \"Renaming thread\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"Chat\",\n      \"readme\": \"Readme\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"New Chat\",\n      \"dialog\": {\n        \"title\": \"Create New Chat\",\n        \"description\": \"This will clear your current chat history. Are you sure you want to continue?\",\n        \"tooltip\": \"New Chat\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"Settings\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API Keys\",\n        \"logout\": \"Logout\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"Required API Keys\",\n    \"description\": \"To use this app, the following API keys are required. The keys are stored on your device's local storage.\",\n    \"success\": {\n      \"saved\": \"Saved successfully\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Select...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"Pick a date\",\n        \"range\": \"Pick a date range\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/es.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"Cancelar\",\n      \"confirm\": \"Confirmar\",\n      \"continue\": \"Continuar\",\n      \"goBack\": \"Volver\",\n      \"reset\": \"Restablecer\",\n      \"submit\": \"Enviar\"\n    },\n    \"status\": {\n      \"loading\": \"Cargando...\",\n      \"error\": {\n        \"default\": \"Ocurrió un error\",\n        \"serverConnection\": \"No se pudo conectar con el servidor\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"Inicia sesión para acceder a la aplicación\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"Correo electrónico\",\n          \"required\": \"el correo electrónico es obligatorio\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"Contraseña\",\n          \"required\": \"la contraseña es obligatoria\"\n        },\n        \"actions\": {\n          \"signin\": \"Iniciar sesión\"\n        },\n        \"alternativeText\": {\n          \"or\": \"O\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"No se pudo iniciar sesión\",\n        \"signin\": \"Intenta iniciar sesión con otra cuenta\",\n        \"oauthSignin\": \"Intenta iniciar sesión con otra cuenta\",\n        \"redirectUriMismatch\": \"El URI de redirección no coincide con la configuración de la aplicación OAuth\",\n        \"oauthCallback\": \"Intenta iniciar sesión con otra cuenta\",\n        \"oauthCreateAccount\": \"Intenta iniciar sesión con otra cuenta\",\n        \"emailCreateAccount\": \"Intenta iniciar sesión con otra cuenta\",\n        \"callback\": \"Intenta iniciar sesión con otra cuenta\",\n        \"oauthAccountNotLinked\": \"Para confirmar tu identidad, inicia sesión con la misma cuenta que usaste originalmente\",\n        \"emailSignin\": \"No se pudo enviar el correo electrónico\",\n        \"emailVerify\": \"Por favor verifica tu correo, se ha enviado un nuevo correo\",\n        \"credentialsSignin\": \"Error al iniciar sesión. Verifica que los datos proporcionados sean correctos\",\n        \"sessionRequired\": \"Por favor inicia sesión para acceder a esta página\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"Continuar con {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"Escribe tu mensaje aquí...\",\n      \"actions\": {\n        \"send\": \"Enviar mensaje\",\n        \"stop\": \"Detener tarea\",\n        \"attachFiles\": \"Adjuntar archivos\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"Usar un mensaje favorito\",\n      \"headline\": \"Mensajes favoritos\",\n      \"remove\": \"Eliminar favorito\",\n      \"empty\": {\n        \"title\": \"Aún no hay prompts guardados\",\n        \"description\": \"Comienza enviando un prompt y márcalo con estrella o marca un prompt de chats anteriores\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"Herramientas\",\n      \"changeTool\": \"Cambiar herramienta\",\n      \"availableTools\": \"Herramientas disponibles\"\n    },\n    \"speech\": {\n      \"start\": \"Comenzar grabación\",\n      \"stop\": \"Detener grabación\",\n      \"connecting\": \"Conectando\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"Arrastra y suelta archivos aquí\",\n      \"browse\": \"Buscar archivos\",\n      \"sizeLimit\": \"Límite:\",\n      \"errors\": {\n        \"failed\": \"Error al subir\",\n        \"cancelled\": \"Carga cancelada de\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"Cancelar subida\",\n        \"removeAttachment\": \"Eliminar adjunto\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"Usando\",\n        \"used\": \"Usado\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"Copiar al portapapeles\",\n          \"success\": \"¡Copiado!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"Útil\",\n        \"negative\": \"No útil\",\n        \"edit\": \"Editar comentario\",\n        \"dialog\": {\n          \"title\": \"Agregar un comentario\",\n          \"submit\": \"Enviar comentario\",\n          \"yourFeedback\": \"Tu comentario...\"\n        },\n        \"status\": {\n          \"updating\": \"Actualizando\",\n          \"updated\": \"Comentario actualizado\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"Últimas entradas\",\n      \"empty\": \"Tan vacío...\",\n      \"show\": \"Mostrar historial\"\n    },\n    \"settings\": {\n      \"title\": \"Panel de configuración\",\n      \"customize\": \"Personaliza la configuración de tu chat aquí\"\n    },\n    \"watermark\": \"Los LLM pueden cometer errores. Verifica la información importante.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"Chats anteriores\",\n      \"filters\": {\n        \"search\": \"Buscar\",\n        \"placeholder\": \"Buscar conversaciones...\"\n      },\n      \"timeframes\": {\n        \"today\": \"Hoy\",\n        \"yesterday\": \"Ayer\",\n        \"previous7days\": \"Últimos 7 días\",\n        \"previous30days\": \"Últimos 30 días\"\n      },\n      \"empty\": \"No se encontraron conversaciones\",\n      \"actions\": {\n        \"close\": \"Cerrar barra lateral\",\n        \"open\": \"Abrir barra lateral\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"Conversación sin título\",\n      \"menu\": {\n        \"rename\": \"Renombrar\",\n        \"share\": \"Compartir\",\n        \"delete\": \"Eliminar\"\n      },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"Compartir enlace del chat\",\n          \"button\": \"Compartir\",\n          \"status\": {\n            \"copied\": \"Enlace copiado\",\n            \"created\": \"¡Enlace de uso compartido creado!\",\n            \"unshared\": \"Uso compartido deshabilitado para este hilo\"\n          },\n          \"error\": {\n            \"create\": \"Error al crear el enlace de uso compartido\",\n            \"unshare\": \"Error al dejar de compartir el hilo\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"Confirmar eliminación\",\n          \"description\": \"Esto eliminará la conversación, sus mensajes y elementos. Esta acción no se puede deshacer\",\n          \"success\": \"Chat eliminado\",\n          \"inProgress\": \"Eliminando chat\"\n        },\n        \"rename\": {\n          \"title\": \"Renombrar conversación\",\n          \"description\": \"Ingresa un nuevo nombre para esta conversación\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"Nombre\",\n              \"placeholder\": \"Ingresa nuevo nombre\"\n            }\n          },\n          \"success\": \"¡Conversación renombrada!\",\n          \"inProgress\": \"Renombrando conversación\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"Chat\",\n      \"readme\": \"Léeme\",\n      \"theme\": {\n        \"light\": \"Tema claro\",\n        \"dark\": \"Tema oscuro\",\n        \"system\": \"Seguir sistema\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"Nuevo chat\",\n      \"dialog\": {\n        \"title\": \"Crear nuevo chat\",\n        \"description\": \"Esto borrará tu historial de chat actual. ¿Seguro que quieres continuar?\",\n        \"tooltip\": \"Nuevo chat\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"Configuración\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"Claves API\",\n        \"logout\": \"Cerrar sesión\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"Claves API requeridas\",\n    \"description\": \"Para usar esta aplicación, se requieren las siguientes claves API. Las claves se almacenan en el almacenamiento local de tu dispositivo.\",\n    \"success\": {\n      \"saved\": \"Guardado exitosamente\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Información\",\n    \"note\": \"Nota\",\n    \"tip\": \"Consejo\",\n    \"important\": \"Importante\",\n    \"warning\": \"Advertencia\",\n    \"caution\": \"Precaución\",\n    \"debug\": \"Depuración\",\n    \"example\": \"Ejemplo\",\n    \"success\": \"Éxito\",\n    \"help\": \"Ayuda\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pendiente\",\n    \"security\": \"Seguridad\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Mejor práctica\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Seleccionar...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"Elige una fecha\",\n        \"range\": \"Elige un rango de fechas\"\n      }\n    }\n  }\n}"
  },
  {
    "path": "backend/chainlit/translations/fr-FR.json",
    "content": "{\n    \"common\": {\n        \"actions\": {\n            \"cancel\": \"Annuler\",\n            \"confirm\": \"Confirmer\",\n            \"continue\": \"Continuer\",\n            \"goBack\": \"Retour\",\n            \"reset\": \"Réinitialiser\",\n            \"submit\": \"Envoyer\"\n        },\n        \"status\": {\n            \"loading\": \"Chargement...\",\n            \"error\": {\n                \"default\": \"Une erreur est survenue\",\n                \"serverConnection\": \"Impossible de joindre le serveur\"\n            }\n        }\n    },\n    \"auth\": {\n        \"login\": {\n            \"title\": \"Connectez-vous pour accéder à l'application\",\n            \"form\": {\n                \"email\": {\n                    \"label\": \"Adresse e-mail\",\n                    \"required\": \"l'e-mail est un champ obligatoire\",\n                    \"placeholder\": \"me@example.com\"\n                },\n                \"password\": {\n                    \"label\": \"Mot de passe\",\n                    \"required\": \"le mot de passe est un champ obligatoire\"\n                },\n                \"actions\": {\n                    \"signin\": \"Se connecter\"\n                },\n                \"alternativeText\": {\n                    \"or\": \"OU\"\n                }\n            },\n            \"errors\": {\n                \"default\": \"Impossible de se connecter\",\n                \"signin\": \"Essayez de vous connecter avec un autre compte\",\n                \"oauthSignin\": \"Essayez de vous connecter avec un autre compte\",\n                \"redirectUriMismatch\": \"L'URI de redirection ne correspond pas à la configuration de l'application oauth\",\n                \"oauthCallback\": \"Essayez de vous connecter avec un autre compte\",\n                \"oauthCreateAccount\": \"Essayez de vous connecter avec un autre compte\",\n                \"emailCreateAccount\": \"Essayez de vous connecter avec un autre compte\",\n                \"callback\": \"Essayez de vous connecter avec un autre compte\",\n                \"oauthAccountNotLinked\": \"Pour confirmer votre identité, connectez-vous avec le même compte que vous avez utilisé à l'origine\",\n                \"emailSignin\": \"L'e-mail n'a pas pu être envoyé\",\n                \"emailVerify\": \"Veuillez vérifier votre e-mail, un nouvel e-mail a été envoyé\",\n                \"credentialsSignin\": \"La connexion a échoué. Vérifiez que les informations que vous avez fournies sont correctes\",\n                \"sessionRequired\": \"Veuillez vous connecter pour accéder à cette page\"\n            }\n        },\n        \"provider\": {\n            \"continue\": \"Continuer avec {{provider}}\"\n        }\n    },\n    \"chat\": {\n        \"input\": {\n            \"placeholder\": \"Tapez votre message ici...\",\n            \"actions\": {\n                \"send\": \"Envoyer le message\",\n                \"stop\": \"Arrêter la tâche\",\n                \"attachFiles\": \"Joindre des fichiers\"\n            }\n        },\n        \"favorites\": {\n          \"use\": \"Utiliser un message favori\",\n          \"headline\": \"Messages favoris\",\n          \"remove\": \"Supprimer des favoris\",\n          \"empty\": {\n            \"title\": \"Aucun prompt enregistré pour le moment\",\n            \"description\": \"Commencez par envoyer un prompt et ajoutez-le aux favoris ou ajoutez un prompt de discussions précédentes aux favoris\"\n          }\n        },\n        \"commands\": {\n            \"button\": \"Outils\",\n            \"changeTool\": \"Changer d'outil\",\n            \"availableTools\": \"Outils disponibles\"\n        },\n        \"speech\": {\n            \"start\": \"Démarrer l'enregistrement\",\n            \"stop\": \"Arrêter l'enregistrement\",\n            \"connecting\": \"Connexion en cours\"\n        },\n        \"fileUpload\": {\n            \"dragDrop\": \"Glissez et déposez des fichiers ici\",\n            \"browse\": \"Parcourir les fichiers\",\n            \"sizeLimit\": \"Limite :\",\n            \"errors\": {\n                \"failed\": \"Échec du téléversement\",\n                \"cancelled\": \"Téléversement annulé de\"\n            },\n            \"actions\": {\n                \"cancelUpload\": \"Annuler le téléversement\",\n                \"removeAttachment\": \"Supprimer la pièce jointe\"\n            }\n        },\n        \"messages\": {\n            \"status\": {\n                \"using\": \"Utilise\",\n                \"used\": \"Utilisé\"\n            },\n            \"actions\": {\n                \"copy\": {\n                    \"button\": \"Copier dans le presse-papiers\",\n                    \"success\": \"Copié !\"\n                }\n            },\n            \"feedback\": {\n                \"positive\": \"Utile\",\n                \"negative\": \"Pas utile\",\n                \"edit\": \"Modifier le commentaire\",\n                \"dialog\": {\n                    \"title\": \"Ajouter un commentaire\",\n                    \"submit\": \"Envoyer le commentaire\",\n                    \"yourFeedback\": \"Votre avis...\"\n                },\n                \"status\": {\n                    \"updating\": \"Mise à jour\",\n                    \"updated\": \"Commentaire mis à jour\"\n                }\n            }\n        },\n        \"history\": {\n            \"title\": \"Dernières entrées\",\n            \"empty\": \"Tellement vide...\",\n            \"show\": \"Afficher l'historique\"\n        },\n        \"settings\": {\n            \"title\": \"Panneau des paramètres\",\n            \"customize\": \"Personnalisez vos paramètres de chat ici\"\n        },\n        \"watermark\": \"Les LLMs peuvent se tromper. Vérifiez les réponses.\"\n    },\n    \"threadHistory\": {\n        \"sidebar\": {\n            \"title\": \"Discussions passées\",\n            \"filters\": {\n                \"search\": \"Rechercher\",\n                \"placeholder\": \"Rechercher des conversations...\"\n            },\n            \"timeframes\": {\n                \"today\": \"Aujourd'hui\",\n                \"yesterday\": \"Hier\",\n                \"previous7days\": \"Les 7 derniers jours\",\n                \"previous30days\": \"Les 30 derniers jours\"\n            },\n            \"empty\": \"Aucun fil de discussion trouvé\",\n            \"actions\": {\n                \"close\": \"Fermer la barre latérale\",\n                \"open\": \"Ouvrir la barre latérale\"\n            }\n        },\n        \"thread\": {\n            \"untitled\": \"Conversation sans titre\",\n            \"menu\": {\n                \"rename\": \"Renommer\",\n                \"share\": \"Partager\",\n                \"delete\": \"Supprimer\"\n            },\n            \"actions\": {\n                \"share\": {\n                    \"title\": \"Partager le lien de la discussion\",\n                    \"button\": \"Partager\",\n                    \"status\": {\n                        \"copied\": \"Lien copié\",\n                        \"created\": \"Lien de partage créé !\",\n                        \"unshared\": \"Partage désactivé pour ce fil\"\n                    },\n                    \"error\": {\n                        \"create\": \"Échec de la création du lien de partage\",\n                        \"unshare\": \"Échec de la désactivation du partage du fil\"\n                    }\n                },\n                \"delete\": {\n                    \"title\": \"Confirmer la suppression\",\n                    \"description\": \"Cela supprimera le fil de discussion ainsi que ses messages et éléments. Cette action ne peut pas être annulée\",\n                    \"success\": \"Discussion supprimée\",\n                    \"inProgress\": \"Suppression de la discussion\"\n                },\n                \"rename\": {\n                    \"title\": \"Renommer le fil de discussion\",\n                    \"description\": \"Entrez un nouveau nom pour ce fil de discussion\",\n                    \"form\": {\n                        \"name\": {\n                            \"label\": \"Nom\",\n                            \"placeholder\": \"Entrez le nouveau nom\"\n                        }\n                    },\n                    \"success\": \"Fil de discussion renommé !\",\n                    \"inProgress\": \"Renommage du fil de discussion\"\n                }\n            }\n        }\n    },\n    \"navigation\": {\n        \"header\": {\n            \"chat\": \"Discussion\",\n            \"readme\": \"Lisez-moi\",\n            \"theme\": {\n                \"light\": \"Thème clair\",\n                \"dark\": \"Thème sombre\",\n                \"system\": \"Suivre le système\"\n            }\n        },\n        \"newChat\": {\n            \"button\": \"Nouvelle discussion\",\n            \"dialog\": {\n                \"title\": \"Créer une nouvelle discussion\",\n                \"description\": \"Cela effacera votre historique de discussion actuel. Êtes-vous sûr de vouloir continuer ?\",\n                \"tooltip\": \"Nouvelle discussion\"\n            }\n        },\n        \"user\": {\n            \"menu\": {\n                \"settings\": \"Paramètres\",\n                \"settingsKey\": \"S\",\n                \"apiKeys\": \"Clés API\",\n                \"logout\": \"Se déconnecter\"\n            }\n        }\n    },\n    \"apiKeys\": {\n        \"title\": \"Clés API requises\",\n        \"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.\",\n        \"success\": {\n            \"saved\": \"Enregistré avec succès\"\n        }\n    },\n    \"alerts\": {\n        \"info\": \"Info\",\n        \"note\": \"Note\",\n        \"tip\": \"Astuce\",\n        \"important\": \"Important\",\n        \"warning\": \"Avertissement\",\n        \"caution\": \"Attention\",\n        \"debug\": \"Débogage\",\n        \"example\": \"Exemple\",\n        \"success\": \"Succès\",\n        \"help\": \"Aide\",\n        \"idea\": \"Idée\",\n        \"pending\": \"En attente\",\n        \"security\": \"Sécurité\",\n        \"beta\": \"Bêta\",\n        \"best-practice\": \"Meilleure pratique\"\n    },\n    \"components\": {\n        \"MultiSelectInput\": {\n        \"placeholder\": \"Sélectionner...\"\n        },\n        \"DatePickerInput\": {\n            \"placeholder\": {\n                \"single\": \"Choisir une date\",\n                \"range\": \"Choisir une plage de dates\"\n            }\n        }\n    }\n}"
  },
  {
    "path": "backend/chainlit/translations/gu.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"રદ કરો\",\n      \"confirm\": \"પુષ્ટિ કરો\",\n      \"continue\": \"ચાલુ રાખો\",\n      \"goBack\": \"પાછા જાઓ\",\n      \"reset\": \"રીસેટ કરો\",\n      \"submit\": \"સબમિટ કરો\"\n    },\n    \"status\": {\n      \"loading\": \"લોડ થઈ રહ્યું છે...\",\n      \"error\": {\n        \"default\": \"એક ભૂલ થઈ\",\n        \"serverConnection\": \"સર્વર સુધી પહોંચી શકાયું નથી\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"એપ્લિકેશન ઍક્સેસ કરવા માટે લૉગિન કરો\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ઈમેલ એડ્રેસ\",\n          \"required\": \"ઈમેલ આવશ્યક છે\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"પાસવર્ડ\",\n          \"required\": \"પાસવર્ડ આવશ્યક છે\"\n        },\n        \"actions\": {\n          \"signin\": \"સાઇન ઇન કરો\"\n        },\n        \"alternativeText\": {\n          \"or\": \"અથવા\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"સાઇન ઇન કરી શકાયું નથી\",\n        \"signin\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"oauthSignin\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"redirectUriMismatch\": \"રીડાયરેક્ટ URI oauth ઍપ કન્ફિગરેશન સાથે મેળ ખાતો નથી\",\n        \"oauthCallback\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"oauthCreateAccount\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"emailCreateAccount\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"callback\": \"અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો\",\n        \"oauthAccountNotLinked\": \"તમારી ઓળખની પુષ્ટિ કરવા માટે, મૂળ રૂપે વાપરેલા એકાઉન્ટથી સાઇન ઇન કરો\",\n        \"emailSignin\": \"ઈમેલ મોકલી શકાયો નથી\",\n        \"emailVerify\": \"કૃપા કરી તમારો ઈમેલ ચકાસો, નવો ઈમેલ મોકલવામાં આવ્યો છે\",\n        \"credentialsSignin\": \"સાઇન ઇન નિષ્ફળ. આપેલી વિગતો સાચી છે કે નહીં તે ચકાસો\",\n        \"sessionRequired\": \"આ પેજને ઍક્સેસ કરવા માટે કૃપા કરી સાઇન ઇન કરો\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} સાથે ચાલુ રાખો\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"અહીં તમારો સંદેશ લખો...\",\n      \"actions\": {\n        \"send\": \"સંદેશ મોકલો\",\n        \"stop\": \"કાર્ય રોકો\",\n        \"attachFiles\": \"ફાઇલ્સ જોડો\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"રેકોર્ડિંગ શરૂ કરો\",\n      \"stop\": \"રેકોર્ડિંગ બંધ કરો\",\n      \"connecting\": \"કનેક્ટ થઈ રહ્યું છે\"\n    },\n    \"favorites\": {\n      \"use\": \"મનપસંદ સંદેશનો ઉપયોગ કરો\",\n      \"headline\": \"મનપસંદ સંદેશાઓ\",\n      \"remove\": \"મનપસંદ સંદેશ દૂર કરો\",\n      \"empty\": {\n        \"title\": \"હજી સુધી કોઈ પ્રોમ્પ્ટ સાચવેલ નથી\",\n        \"description\": \"એક પ્રોમ્પ્ટ મોકલીને અને તેને સ્ટાર કરીને શરૂઆત કરો અથવા અગાઉની ચેટમાંથી કોઈ પ્રોમ્પ્ટને સ્ટાર કરો\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"ટૂલ્સ\",\n      \"changeTool\": \"ટૂલ બદલો\",\n      \"availableTools\": \"ઉપલબ્ધ ટૂલ્સ\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"અહીં ફાઇલ્સ ખેંચો અને છોડો\",\n      \"browse\": \"ફાઇલ્સ બ્રાઉઝ કરો\",\n      \"sizeLimit\": \"મર્યાદા:\",\n      \"errors\": {\n        \"failed\": \"અપલોડ કરવામાં નિષ્ફળ\",\n        \"cancelled\": \"અપલોડ રદ કર્યું\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"અપલોડ રદ કરો\",\n        \"removeAttachment\": \"જોડાણ દૂર કરો\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"વાપરી રહ્યા છે\",\n        \"used\": \"વપરાયેલ\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"ક્લિપબોર્ડ પર કૉપિ કરો\",\n          \"success\": \"કૉપિ થયું!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"ઉપયોગી\",\n        \"negative\": \"બિનઉપયોગી\",\n        \"edit\": \"પ્રતિસાદ સંપાદિત કરો\",\n        \"dialog\": {\n          \"title\": \"ટિપ્પણી ઉમેરો\",\n          \"submit\": \"પ્રતિસાદ સબમિટ કરો\",\n          \"yourFeedback\": \"તમારો પ્રતિસાદ...\"\n        },\n        \"status\": {\n          \"updating\": \"અપડેટ થઈ રહ્યું છે\",\n          \"updated\": \"પ્રતિસાદ અપડેટ થયો\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"છેલ્લા ઇનપુટ્સ\",\n      \"empty\": \"ખાલી છે...\",\n      \"show\": \"ઇતિહાસ બતાવો\"\n    },\n    \"settings\": {\n      \"title\": \"સેટિંગ્સ પેનલ\",\n      \"customize\": \"તમારા ચેટ સેટિંગ્સ અહીં કસ્ટમાઇઝ કરો\"\n    },\n    \"watermark\": \"LLM ભૂલો કરી શકે છે. મહત્વપૂર્ણ માહિતી તપાસવાનું વિચારો.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"પાછલી ચેટ્સ\",\n      \"filters\": {\n        \"search\": \"શોધો\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"આજે\",\n        \"yesterday\": \"ગઈકાલે\",\n        \"previous7days\": \"પાછલા 7 દિવસ\",\n        \"previous30days\": \"પાછલા 30 દિવસ\"\n      },\n      \"empty\": \"કોઈ થ્રેડ્સ મળ્યા નથી\",\n      \"actions\": {\n        \"close\": \"સાઇડબાર બંધ કરો\",\n        \"open\": \"સાઇડબાર ખોલો\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"શીર્ષક વગરની વાતચીત\",\n      \"menu\": {\n          \"rename\": \"નામ બદલો\",\n          \"share\": \"શેર કરો\",\n          \"delete\": \"Delete\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"ચેટની લિંક શેર કરો\",\n          \"button\": \"શેર કરો\",\n          \"status\": {\n            \"copied\": \"લિંક કૉપિ થઈ\",\n            \"created\": \"શેર લિંક બનાવાઈ!\",\n            \"unshared\": \"આ થ્રેડ માટે શેરિંગ નિષ્ક્રિય છે\"\n          },\n          \"error\": {\n            \"create\": \"શેર લિંક બનાવવામાં નિષ્ફળ\",\n            \"unshare\": \"થ્રેડ અનશેર કરવામાં નિષ્ફળ\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"કાઢી નાખવાની પુષ્ટિ કરો\",\n          \"description\": \"આ થ્રેડ અને તેના સંદેશાઓ અને તત્વોને કાઢી નાખશે. આ ક્રિયા પાછી ફેરવી શકાશે નહીં\",\n          \"success\": \"ચેટ કાઢી નાખી\",\n          \"inProgress\": \"ચેટ કાઢી નાખી રહ્યા છીએ\"\n        },\n        \"rename\": {\n          \"title\": \"થ્રેડનું નામ બદલો\",\n          \"description\": \"આ થ્રેડ માટે નવું નામ દાખલ કરો\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"નામ\",\n              \"placeholder\": \"નવું નામ દાખલ કરો\"\n            }\n          },\n          \"success\": \"થ્રેડનું નામ બદલાયું!\",\n          \"inProgress\": \"થ્રેડનું નામ બદલી રહ્યા છીએ\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"ચેટ\",\n      \"readme\": \"વાંચો\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"નવી ચેટ\",\n      \"dialog\": {\n        \"title\": \"નવી ચેટ બનાવો\",\n        \"description\": \"આ તમારો વર્તમાન ચેટ ઇતિહાસ સાફ કરશે. શું તમે ચાલુ રાખવા માંગો છો?\",\n        \"tooltip\": \"નવી ચેટ\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"સેટિંગ્સ\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API કી\",\n        \"logout\": \"લૉગઆઉટ\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"જરૂરી API કી\",\n    \"description\": \"આ એપ્લિકેશન વાપરવા માટે, નીચેની API કી જરૂરી છે. કી તમારા ડિવાઇસના લોકલ સ્ટોરેજમાં સંગ્રહિત થશે.\",\n    \"success\": {\n      \"saved\": \"સફળતાપૂર્વક સાચવ્યું\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"બેંચી લો...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"તારીખ પસંદ કરો\",\n        \"range\": \"તારીખની શ્રેણી પસંદ કરો\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/he-IL.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"ביטול\",\n      \"confirm\": \"אישור\",\n      \"continue\": \"המשך\",\n      \"goBack\": \"חזור\",\n      \"reset\": \"איפוס\",\n      \"submit\": \"שלח\"\n    },\n    \"status\": {\n      \"loading\": \"טוען...\",\n      \"error\": {\n        \"default\": \"אירעה שגיאה\",\n        \"serverConnection\": \"לא ניתן להתחבר לשרת\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"התחבר כדי לגשת לאפליקציה\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"כתובת אימייל\",\n          \"required\": \"שדה האימייל הוא שדה חובה\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"סיסמה\",\n          \"required\": \"שדה הסיסמה הוא שדה חובה\"\n        },\n        \"actions\": {\n          \"signin\": \"התחבר\"\n        },\n        \"alternativeText\": {\n          \"or\": \"או\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"לא ניתן להתחבר\",\n        \"signin\": \"נסה להתחבר עם חשבון אחר\",\n        \"oauthSignin\": \"נסה להתחבר עם חשבון אחר\",\n        \"redirectUriMismatch\": \"כתובת ההפניה אינה תואמת את תצורת אפליקציית OAuth\",\n        \"oauthCallback\": \"נסה להתחבר עם חשבון אחר\",\n        \"oauthCreateAccount\": \"נסה להתחבר עם חשבון אחר\",\n        \"emailCreateAccount\": \"נסה להתחבר עם חשבון אחר\",\n        \"callback\": \"נסה להתחבר עם חשבון אחר\",\n        \"oauthAccountNotLinked\": \"כדי לאמת את זהותך, התחבר עם אותו חשבון בו השתמשת במקור\",\n        \"emailSignin\": \"לא ניתן היה לשלוח את האימייל\",\n        \"emailVerify\": \"אנא אמת את האימייל שלך, נשלח אימייל חדש\",\n        \"credentialsSignin\": \"ההתחברות נכשלה. בדוק שהפרטים שהזנת נכונים\",\n        \"sessionRequired\": \"אנא התחבר כדי לגשת לדף זה\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"המשך עם {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"הקלד את ההודעה שלך כאן...\",\n      \"actions\": {\n        \"send\": \"שלח הודעה\",\n        \"stop\": \"עצור משימה\",\n        \"attachFiles\": \"צרף קבצים\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"התחל הקלטה\",\n      \"stop\": \"עצור הקלטה\",\n      \"connecting\": \"מתחבר\"\n    },\n    \"favorites\": {\n      \"use\": \"השתמש בהודעה מועדפת\",\n      \"headline\": \"הודעות מועדפות\",\n      \"remove\": \"הסר מהמועדפים\",\n      \"empty\": {\n        \"title\": \"עדיין אין הנחיות שמורות\",\n        \"description\": \"התחל בשליחת הנחיה וסמן אותה בכוכב או סמן הנחיה משיחות קודמות\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"כלים\",\n      \"changeTool\": \"שנה כלי\",\n      \"availableTools\": \"כלים זמינים\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"גרור ושחרר קבצים כאן\",\n      \"browse\": \"עיין בקבצים\",\n      \"sizeLimit\": \"מגבלה:\",\n      \"errors\": {\n        \"failed\": \"העלאה נכשלה\",\n        \"cancelled\": \"העלאה בוטלה של\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"ביטול העלאה\",\n        \"removeAttachment\": \"הסרת קובץ מצורף\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"משתמש ב\",\n        \"used\": \"השתמש ב\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"העתק ללוח\",\n          \"success\": \"הועתק!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"מועיל\",\n        \"negative\": \"לא מועיל\",\n        \"edit\": \"ערוך משוב\",\n        \"dialog\": {\n          \"title\": \"הוסף תגובה\",\n          \"submit\": \"שלח משוב\",\n          \"yourFeedback\": \"המשוב שלך...\"\n        },\n        \"status\": {\n          \"updating\": \"מעדכן\",\n          \"updated\": \"המשוב עודכן\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"קלטים אחרונים\",\n      \"empty\": \"כל כך ריק...\",\n      \"show\": \"הצג היסטוריה\"\n    },\n    \"settings\": {\n      \"title\": \"פאנל הגדרות\",\n      \"customize\": \"התאם אישית את הגדרות הצ'אט שלך כאן\"\n    },\n    \"watermark\": \"מודלי שפה גדולים עלולים לעשות טעויות. כדאי לבדוק מידע חשוב.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"צ'אטים קודמים\",\n      \"filters\": {\n        \"search\": \"חיפוש\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"היום\",\n        \"yesterday\": \"אתמול\",\n        \"previous7days\": \"7 ימים אחרונים\",\n        \"previous30days\": \"30 ימים אחרונים\"\n      },\n      \"empty\": \"לא נמצאו שיחות\",\n      \"actions\": {\n        \"close\": \"סגור סרגל צד\",\n        \"open\": \"פתח סרגל צד\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"שיחה ללא כותרת\",\n      \"menu\": {\n          \"rename\": \"שינוי שם\",\n          \"share\": \"שיתוף\",\n          \"delete\": \"Delete\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"שיתוף קישור לשיחה\",\n          \"button\": \"שיתוף\",\n          \"status\": {\n            \"copied\": \"הקישור הועתק\",\n            \"created\": \"קישור השיתוף נוצר!\",\n            \"unshared\": \"השיתוף בוטל עבור שיחה זו\"\n          },\n          \"error\": {\n            \"create\": \"יצירת קישור השיתוף נכשלה\",\n            \"unshare\": \"ביטול השיתוף של השיחה נכשל\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"אשר מחיקה\",\n          \"description\": \"פעולה זו תמחק את השיחה וכן את ההודעות והאלמנטים שלה. לא ניתן לבטל פעולה זו\",\n          \"success\": \"הצ'אט נמחק\",\n          \"inProgress\": \"מוחק צ'אט\"\n        },\n        \"rename\": {\n          \"title\": \"שנה שם שיחה\",\n          \"description\": \"הזן שם חדש לשיחה זו\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"שם\",\n              \"placeholder\": \"הזן שם חדש\"\n            }\n          },\n          \"success\": \"שם השיחה שונה!\",\n          \"inProgress\": \"משנה שם שיחה\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"צ'אט\",\n      \"readme\": \"קרא אותי\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"צ'אט חדש\",\n      \"dialog\": {\n        \"title\": \"צור צ'אט חדש\",\n        \"description\": \"פעולה זו תנקה את היסטוריית הצ'אט הנוכחית שלך. האם אתה בטוח שברצונך להמשיך?\",\n        \"tooltip\": \"צ'אט חדש\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"הגדרות\",\n        \"settingsKey\": \"ה\",\n        \"apiKeys\": \"מפתחות API\",\n        \"logout\": \"התנתק\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"מפתחות API נדרשים\",\n    \"description\": \"כדי להשתמש באפליקציה זו, נדרשים מפתחות API הבאים. המפתחות מאוחסנים באחסון המקומי של המכשיר שלך.\",\n    \"success\": {\n      \"saved\": \"נשמר בהצלחה\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"בחר...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"בחר תאריך\",\n        \"range\": \"בחר טווח תאריכים\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/hi.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"रद्द करें\",\n      \"confirm\": \"पुष्टि करें\",\n      \"continue\": \"जारी रखें\",\n      \"goBack\": \"वापस जाएं\",\n      \"reset\": \"रीसेट करें\",\n      \"submit\": \"जमा करें\"\n    },\n    \"status\": {\n      \"loading\": \"लोड हो रहा है...\",\n      \"error\": {\n        \"default\": \"एक त्रुटि हुई\",\n        \"serverConnection\": \"सर्वर से संपर्क नहीं हो पा रहा\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"ऐप का उपयोग करने के लिए लॉगिन करें\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ईमेल पता\",\n          \"required\": \"ईमेल एक आवश्यक फ़ील्ड है\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"पासवर्ड\",\n          \"required\": \"पासवर्ड एक आवश्यक फ़ील्ड है\"\n        },\n        \"actions\": {\n          \"signin\": \"साइन इन करें\"\n        },\n        \"alternativeText\": {\n          \"or\": \"या\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"साइन इन करने में असमर्थ\",\n        \"signin\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"oauthSignin\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"redirectUriMismatch\": \"रीडायरेक्ट URI oauth ऐप कॉन्फ़िगरेशन से मेल नहीं खा रहा\",\n        \"oauthCallback\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"oauthCreateAccount\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"emailCreateAccount\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"callback\": \"किसी दूसरे खाते से साइन इन करने का प्रयास करें\",\n        \"oauthAccountNotLinked\": \"अपनी पहचान की पुष्टि करने के लिए, उसी खाते से साइन इन करें जिसका उपयोग आपने मूल रूप से किया था\",\n        \"emailSignin\": \"ईमेल नहीं भेजा जा सका\",\n        \"emailVerify\": \"कृपया अपना ईमेल सत्यापित करें, एक नया ईमेल भेजा गया है\",\n        \"credentialsSignin\": \"साइन इन विफल। आपके द्वारा प्रदान किए गए विवरण की जांच करें\",\n        \"sessionRequired\": \"इस पृष्ठ तक पहुंचने के लिए कृपया साइन इन करें\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} के साथ जारी रखें\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"अपना संदेश यहां टाइप करें...\",\n      \"actions\": {\n        \"send\": \"संदेश भेजें\",\n        \"stop\": \"कार्य रोकें\",\n        \"attachFiles\": \"फ़ाइलें संलग्न करें\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"रिकॉर्डिंग शुरू करें\",\n      \"stop\": \"रिकॉर्डिंग रोकें\",\n      \"connecting\": \"कनेक्ट हो रहा है\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"फ़ाइलों को यहां खींचें और छोड़ें\",\n      \"browse\": \"फ़ाइलें ब्राउज़ करें\",\n      \"sizeLimit\": \"सीमा:\",\n      \"errors\": {\n        \"failed\": \"अपलोड करने में विफल\",\n        \"cancelled\": \"का अपलोड रद्द किया गया\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"अपलोड रद्द करें\",\n        \"removeAttachment\": \"संलग्नक हटाएं\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"पसंदीदा संदेश का उपयोग करें\",\n      \"headline\": \"पसंदीदा संदेश\",\n      \"remove\": \"पसंदीदा हटाएं\",\n      \"empty\": {\n        \"title\": \"अभी तक कोई प्रॉम्प्ट सहेजा नहीं गया\",\n        \"description\": \"एक प्रॉम्प्ट भेजकर और उसे स्टार करके शुरू करें या पिछली चैट से किसी प्रॉम्प्ट को स्टार करें\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"उपकरण\",\n      \"changeTool\": \"उपकरण बदलें\",\n      \"availableTools\": \"उपलब्ध उपकरण\"\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"उपयोग कर रहे हैं\",\n        \"used\": \"उपयोग किया\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"क्लिपबोर्ड पर कॉपी करें\",\n          \"success\": \"कॉपी किया गया!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"सहायक\",\n        \"negative\": \"सहायक नहीं\",\n        \"edit\": \"प्रतिक्रिया संपादित करें\",\n        \"dialog\": {\n          \"title\": \"टिप्पणी जोड़ें\",\n          \"submit\": \"प्रतिक्रिया जमा करें\",\n          \"yourFeedback\": \"आपकी प्रतिक्रिया...\"\n        },\n        \"status\": {\n          \"updating\": \"अपडेट हो रहा है\",\n          \"updated\": \"प्रतिक्रिया अपडेट की गई\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"पिछले इनपुट\",\n      \"empty\": \"कुछ भी नहीं है...\",\n      \"show\": \"इतिहास दिखाएं\"\n    },\n    \"settings\": {\n      \"title\": \"सेटिंग्स पैनल\",\n      \"customize\": \"अपने चैट सेटिंग्स को यहां अनुकूलित करें\"\n    },\n    \"watermark\": \"एलएलएम गलतियां कर सकते हैं। महत्वपूर्ण जानकारी की जांच करने पर विचार करें।\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"पिछली चैट\",\n      \"filters\": {\n        \"search\": \"खोजें\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"आज\",\n        \"yesterday\": \"कल\",\n        \"previous7days\": \"पिछले 7 दिन\",\n        \"previous30days\": \"पिछले 30 दिन\"\n      },\n      \"empty\": \"कोई थ्रेड नहीं मिला\",\n      \"actions\": {\n        \"close\": \"साइडबार बंद करें\",\n        \"open\": \"साइडबार खोलें\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"शीर्षकहीन वार्तालाप\",\n      \"menu\": {\n          \"rename\": \"नाम बदलें\",\n          \"share\": \"साझा करें\",\n          \"delete\": \"Delete\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"चैट का लिंक साझा करें\",\n          \"button\": \"साझा करें\",\n          \"status\": {\n            \"copied\": \"लिंक कॉपी किया गया\",\n            \"created\": \"शेयर लिंक बनाया गया!\",\n            \"unshared\": \"इस थ्रेड के लिए साझा करना निष्क्रिय है\"\n          },\n          \"error\": {\n            \"create\": \"शेयर लिंक बनाने में विफल\",\n            \"unshare\": \"थ्रेड को अनशेयर करने में विफल\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"हटाने की पुष्टि करें\",\n          \"description\": \"यह थ्रेड और इसके संदेशों और तत्वों को हटा देगा। यह क्रिया वापस नहीं की जा सकती\",\n          \"success\": \"चैट हटा दी गई\",\n          \"inProgress\": \"चैट हटाई जा रही है\"\n        },\n        \"rename\": {\n          \"title\": \"थ्रेड का नाम बदलें\",\n          \"description\": \"इस थ्रेड के लिए एक नया नाम दर्ज करें\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"नाम\",\n              \"placeholder\": \"नया नाम दर्ज करें\"\n            }\n          },\n          \"success\": \"थ्रेड का नाम बदल दिया गया!\",\n          \"inProgress\": \"थ्रेड का नाम बदला जा रहा है\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"चैट\",\n      \"readme\": \"रीडमी\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"नई चैट\",\n      \"dialog\": {\n        \"title\": \"नई चैट बनाएं\",\n        \"description\": \"यह आपका वर्तमान चैट इतिहास साफ़ कर देगा। क्या आप जारी रखना चाहते हैं?\",\n        \"tooltip\": \"नई चैट\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"सेटिंग्स\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API कुंजियां\",\n        \"logout\": \"लॉगआउट\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"आवश्यक API कुंजियां\",\n    \"description\": \"इस ऐप का उपयोग करने के लिए, निम्नलिखित API कुंजियां आवश्यक हैं। कुंजियां आपके डिवाइस के स्थानीय संग्रहण में संग्रहीत की जाती हैं।\",\n    \"success\": {\n      \"saved\": \"सफलतापूर्वक सहेजा गया\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"चुनें...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"एक तारीख चुनें\",\n        \"range\": \"तारीख सीमा चुनें\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/it.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"Cancella\",\n      \"confirm\": \"Conferma\",\n      \"continue\": \"Continua\",\n      \"goBack\": \"Ritorna\",\n      \"reset\": \"Reset\",\n      \"submit\": \"Invia\"\n    },\n    \"status\": {\n      \"loading\": \"Caricamento...\",\n      \"error\": {\n        \"default\": \"Si è verificato un errore\",\n        \"serverConnection\": \"Impossibile connettersi al server\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"Accedi per utilizzare l'app\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"Indirizzo email\",\n          \"required\": \"l'email è un campo obbligatorio\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"Password\",\n          \"required\": \"la password è un campo obbligatorio\"\n        },\n        \"actions\": {\n          \"signin\": \"Accedi\"\n        },\n        \"alternativeText\": {\n          \"or\": \"O\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"Impossibile effettuare l'accesso\",\n        \"signin\": \"Prova ad accedere con un account diverso\",\n        \"oauthSignin\": \"Prova ad accedere con un account diverso\",\n        \"redirectUriMismatch\": \"L'URI di reindirizzamento non corrisponde alla configurazione dell'app OAuth\",\n        \"oauthCallback\": \"Prova ad accedere con un account diverso\",\n        \"oauthCreateAccount\": \"Prova ad accedere con un account diverso\",\n        \"emailCreateAccount\": \"Prova ad accedere con un account diverso\",\n        \"callback\": \"Prova ad accedere con un account diverso\",\n        \"oauthAccountNotLinked\": \"Per confermare la tua identità, accedi con lo stesso account che hai usato in precedenza\",\n        \"emailSignin\": \"Impossibile inviare l'email\",\n        \"emailVerify\": \"Verifica la tua email, è stata inviata una nuova email\",\n        \"credentialsSignin\": \"Accesso non riuscito. Verifica che i dati forniti siano corretti\",\n        \"sessionRequired\": \"Accedi per visualizzare questa pagina\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"Continua con {{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"Scrivi un messaggio...\",\n      \"actions\": {\n        \"send\": \"Invia messaggio\",\n        \"stop\": \"Interrompi attività\",\n        \"attachFiles\": \"Allega file\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"Usa un messaggio preferito\",\n      \"headline\": \"Messaggi preferiti\",\n      \"remove\": \"Rimuovi preferito\",\n      \"empty\": {\n        \"title\": \"Nessun prompt salvato ancora\",\n        \"description\": \"Inizia inviando un prompt e aggiungilo ai preferiti o aggiungi un prompt dalle chat precedenti\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"Strumenti\",\n      \"changeTool\": \"Cambia strumento\",\n      \"availableTools\": \"Strumenti disponibili\"\n    },\n    \"speech\": {\n      \"start\": \"Inizia registrazione\",\n      \"stop\": \"Interrompi registrazione\",\n      \"connecting\": \"Connettendo\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"Trascina e rilascia i file qui\",\n      \"browse\": \"Sfoglia file\",\n      \"sizeLimit\": \"Limite:\",\n      \"errors\": {\n        \"failed\": \"Caricamento file non riuscito\",\n        \"cancelled\": \"Caricamento annullato di\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"Annulla caricamento\",\n        \"removeAttachment\": \"Rimuovi allegato\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"In uso\",\n        \"used\": \"Utilizzato\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"Copia negli appunti\",\n          \"success\": \"Copiato!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"Utile\",\n        \"negative\": \"Non utile\",\n        \"edit\": \"Modifica feedback\",\n        \"dialog\": {\n          \"title\": \"Aggiungi un commento\",\n          \"submit\": \"Invia feedback\",\n          \"yourFeedback\": \"Il tuo feedback...\"\n        },\n        \"status\": {\n          \"updating\": \"Aggiornamento\",\n          \"updated\": \"Feedback aggiornato\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"Cronologia chat\",\n      \"empty\": \"Così vuoto...\",\n      \"show\": \"Mostra cronologia\"\n    },\n    \"settings\": {\n      \"title\": \"Impostazioni\",\n      \"customize\": \"Personalizza le impostazioni della tua chat qui\"\n    },\n    \"watermark\": \"Gli LLMS possono commettere errori. Verifica le info importanti.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"Chat precedenti\",\n      \"filters\": {\n        \"search\": \"Cerca\",\n        \"placeholder\": \"Cerca conversazioni...\"\n      },\n      \"timeframes\": {\n        \"today\": \"Oggi\",\n        \"yesterday\": \"Ieri\",\n        \"previous7days\": \"Ultimi 7 giorni\",\n        \"previous30days\": \"Ultimi 30 giorni\"\n      },\n      \"empty\": \"Nessuna chat trovata\",\n      \"actions\": {\n        \"close\": \"Chiudi barra laterale\",\n        \"open\": \"Apri barra laterale\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"Conversazione senza titolo\",\n      \"menu\": {\n          \"rename\": \"Rinomina\",\n          \"share\": \"Condividi\",\n          \"delete\": \"Elimina\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"Condividi link conversazione\",\n          \"button\": \"Condividi\",\n          \"status\": {\n            \"copied\": \"Link copiato\",\n            \"created\": \"Link di condivisione creato!\",\n            \"unshared\": \"Condivisione disabilitata per questa chat\"\n          },\n          \"error\": {\n            \"create\": \"Impossibile creare il link di condivisione\",\n            \"unshare\": \"Impossibile annullare la condivisione della chat\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"Conferma eliminazione\",\n          \"description\": \"Stai per eliminare la chat insieme ai suoi messaggi ed elementi. Questa azione non può essere annullata\",\n          \"success\": \"Chat eliminata\",\n          \"inProgress\": \"Eliminazione chat\"\n        },\n        \"rename\": {\n          \"title\": \"Rinomina chat\",\n          \"description\": \"Inserisci un nuovo nome per questa conversazione\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"Nome\",\n              \"placeholder\": \"Inserisci nuovo nome\"\n            }\n          },\n          \"success\": \"Chat rinominata!\",\n          \"inProgress\": \"Rinomina chat\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"Chat\",\n      \"readme\": \"Leggimi\",\n      \"theme\": {\n        \"light\": \"Tema Chiaro\",\n        \"dark\": \"Tema Scuro\",\n        \"system\": \"Usa tema di sistema\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"Nuova Chat\",\n      \"dialog\": {\n        \"title\": \"Crea Nuova Chat\",\n        \"description\": \"Sei sicuro di voler creare una nuova chat? La chat corrente verrà chiusa.\",\n        \"tooltip\": \"Nuova Chat\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"Impostazioni\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"Chiavi API\",\n        \"logout\": \"Disconnettiti\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"Chiavi API richieste\",\n    \"description\": \"Per utilizzare l'app, sono necessarie le seguenti chiavi API. Le chiavi sono salvate nella memoria locale del tuo dispositivo.\",\n    \"success\": {\n      \"saved\": \"Salvataggio riuscito\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Nota\",\n    \"tip\": \"Suggerimento\",\n    \"important\": \"Importante\",\n    \"warning\": \"Avviso\",\n    \"caution\": \"Attenzione\",\n    \"debug\": \"Debug\",\n    \"example\": \"Esempio\",\n    \"success\": \"Successo\",\n    \"help\": \"Aiuto\",\n    \"idea\": \"Idea\",\n    \"pending\": \"In sospeso\",\n    \"security\": \"Sicurezza\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Miglior Soluzione\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Seleziona...\"\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/ja.json",
    "content": "{\n    \"common\": {\n      \"actions\": {\n        \"cancel\": \"キャンセル\",\n        \"confirm\": \"確認\",\n        \"continue\": \"続ける\",\n        \"goBack\": \"戻る\",\n        \"reset\": \"リセット\",\n        \"submit\": \"送信\"\n      },\n      \"status\": {\n        \"loading\": \"読み込み中...\",\n        \"error\": {\n          \"default\": \"エラーが発生しました\",\n          \"serverConnection\": \"サーバーに接続できませんでした\"\n        }\n      }\n    },\n    \"auth\": {\n      \"login\": {\n        \"title\": \"アプリにログイン\",\n        \"form\": {\n          \"email\": {\n            \"label\": \"メールアドレス\",\n            \"required\": \"メールアドレスは必須項目です\",\n            \"placeholder\": \"me@example.com\"\n          },\n          \"password\": {\n            \"label\": \"パスワード\",\n            \"required\": \"パスワードは必須項目です\"\n          },\n          \"actions\": {\n            \"signin\": \"サインイン\"\n          },\n          \"alternativeText\": {\n            \"or\": \"または\"\n          }\n        },\n        \"errors\": {\n          \"default\": \"サインインできません\",\n          \"signin\": \"別のアカウントでサインインしてください\",\n          \"oauthSignin\": \"別のアカウントでサインインしてください\",\n          \"redirectUriMismatch\": \"リダイレクトURIがOAuthアプリの設定と一致しません\",\n          \"oauthCallback\": \"別のアカウントでサインインしてください\",\n          \"oauthCreateAccount\": \"別のアカウントでサインインしてください\",\n          \"emailCreateAccount\": \"別のアカウントでサインインしてください\",\n          \"callback\": \"別のアカウントでサインインしてください\",\n          \"oauthAccountNotLinked\": \"本人確認のため、最初に使用したのと同じアカウントでサインインしてください\",\n          \"emailSignin\": \"メールを送信できませんでした\",\n          \"emailVerify\": \"メールアドレスを確認してください。新しいメールが送信されました\",\n          \"credentialsSignin\": \"サインインに失敗しました。入力した情報が正しいか確認してください\",\n          \"sessionRequired\": \"このページにアクセスするにはサインインしてください\"\n        }\n      },\n      \"provider\": {\n        \"continue\": \"{{provider}}で続ける\"\n      }\n    },\n    \"chat\": {\n      \"input\": {\n        \"placeholder\": \"メッセージを入力してください...\",\n        \"actions\": {\n          \"send\": \"メッセージを送信\",\n          \"stop\": \"タスクを停止\",\n          \"attachFiles\": \"ファイルを添付\"\n        }\n      },\n      \"speech\": {\n        \"start\": \"録音開始\",\n        \"stop\": \"録音停止\",\n        \"connecting\": \"接続中\"\n      },\n      \"favorites\": {\n        \"use\": \"お気に入りのメッセージを使用\",\n        \"headline\": \"お気に入りのメッセージ\",\n        \"remove\": \"お気に入りを削除\",\n        \"empty\": {\n          \"title\": \"保存されたプロンプトがまだありません\",\n          \"description\": \"プロンプトを送信してスターを付けるか、以前のチャットからプロンプトをスターしてください\"\n        }\n      },\n      \"commands\": {\n        \"button\": \"ツール\",\n        \"changeTool\": \"ツールを変更\",\n        \"availableTools\": \"利用可能なツール\"\n      },\n      \"fileUpload\": {\n        \"dragDrop\": \"ここにファイルをドラッグ＆ドロップ\",\n        \"sizeLimit\": \"制限：\",\n        \"errors\": {\n          \"failed\": \"アップロードに失敗しました\",\n          \"cancelled\": \"アップロードをキャンセルしました：\"\n        },\n        \"actions\": {\n          \"cancelUpload\": \"アップロードをキャンセル\",\n          \"removeAttachment\": \"添付ファイルを削除\"\n        }\n      },\n      \"messages\": {\n        \"status\": {\n          \"using\": \"使用中\",\n          \"used\": \"使用済み\"\n        },\n        \"actions\": {\n          \"copy\": {\n            \"button\": \"クリップボードにコピー\",\n            \"success\": \"コピーしました！\"\n          }\n        },\n        \"feedback\": {\n          \"positive\": \"役に立った\",\n          \"negative\": \"役に立たなかった\",\n          \"edit\": \"フィードバックを編集\",\n          \"dialog\": {\n            \"title\": \"コメントを追加\",\n            \"submit\": \"フィードバックを送信\",\n            \"yourFeedback\": \"あなたのフィードバック...\"\n          },\n          \"status\": {\n            \"updating\": \"更新中\",\n            \"updated\": \"フィードバックを更新しました\"\n          }\n        }\n      },\n      \"history\": {\n        \"title\": \"最近の入力\",\n        \"empty\": \"何もありません...\",\n        \"show\": \"履歴を表示\"\n      },\n      \"settings\": {\n        \"title\": \"設定パネル\",\n        \"customize\": \"ここでチャット設定をカスタマイズします\"\n      },\n      \"watermark\": \"大規模言語モデルは間違いを犯す可能性があります。重要な情報については確認を検討してください。\"\n    },\n    \"threadHistory\": {\n      \"sidebar\": {\n        \"title\": \"過去のチャット\",\n        \"filters\": {\n          \"search\": \"検索\",\n          \"placeholder\": \"Search conversations...\"\n        },\n        \"timeframes\": {\n          \"today\": \"今日\",\n          \"yesterday\": \"昨日\",\n          \"previous7days\": \"過去7日間\",\n          \"previous30days\": \"過去30日間\"\n        },\n        \"empty\": \"スレッドが見つかりません\",\n        \"actions\": {\n          \"close\": \"サイドバーを閉じる\",\n          \"open\": \"サイドバーを開く\"\n        }\n      },\n      \"thread\": {\n        \"untitled\": \"無題の会話\",\n        \"menu\": {\n          \"rename\": \"名前を変更\",\n          \"share\": \"共有\",\n          \"delete\": \"削除\"\n        },\n        \"actions\": {\n          \"share\": {\n            \"title\": \"チャットのリンクを共有\",\n            \"button\": \"共有\",\n            \"status\": {\n              \"copied\": \"リンクをコピーしました\",\n              \"created\": \"共有リンクを作成しました！\",\n              \"unshared\": \"このスレッドの共有を無効にしました\"\n            },\n            \"error\": {\n              \"create\": \"共有リンクの作成に失敗しました\",\n              \"unshare\": \"スレッドの共有解除に失敗しました\"\n            }\n          },\n          \"delete\": {\n            \"title\": \"削除の確認\",\n            \"description\": \"このスレッドとそのメッセージ、要素が削除されます。この操作は取り消せません\",\n            \"success\": \"チャットを削除しました\",\n            \"inProgress\": \"チャットを削除中\"\n          },\n          \"rename\": {\n            \"title\": \"スレッドの名前を変更\",\n            \"description\": \"このスレッドの新しい名前を入力してください\",\n            \"form\": {\n              \"name\": {\n                \"label\": \"名前\",\n                \"placeholder\": \"新しい名前を入力\"\n              }\n            },\n            \"success\": \"スレッド名を変更しました！\",\n            \"inProgress\": \"スレッド名を変更中\"\n          }\n        }\n      }\n    },\n    \"navigation\": {\n      \"header\": {\n        \"chat\": \"チャット\",\n        \"readme\": \"説明書\",\n        \"theme\": {\n          \"light\": \"Light Theme\",\n          \"dark\": \"Dark Theme\",\n          \"system\": \"Follow System\"\n        }\n      },\n      \"newChat\": {\n        \"button\": \"新規チャット\",\n        \"dialog\": {\n          \"title\": \"新規チャットの作成\",\n          \"description\": \"現在のチャット履歴がクリアされます。続行しますか？\",\n          \"tooltip\": \"新規チャット\"\n        }\n      },\n      \"user\": {\n        \"menu\": {\n          \"settings\": \"設定\",\n          \"settingsKey\": \"S\",\n          \"apiKeys\": \"APIキー\",\n          \"logout\": \"ログアウト\"\n        }\n      }\n    },\n    \"apiKeys\": {\n      \"title\": \"必要なAPIキー\",\n      \"description\": \"このアプリを使用するには、以下のAPIキーが必要です。キーはお使いのデバイスのローカルストレージに保存されます。\",\n      \"success\": {\n        \"saved\": \"保存が完了しました\"\n      }\n    },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"選択...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"日付を選択\",\n        \"range\": \"日付範囲を選択\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/kn.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"ರದ್ದುಮಾಡಿ\",\n      \"confirm\": \"ದೃಢೀಕರಿಸಿ\",\n      \"continue\": \"ಮುಂದುವರಿಸಿ\",\n      \"goBack\": \"ಹಿಂದೆ ಹೋಗಿ\",\n  \"reset\": \"ಮರುಹೊಂದಿಸಿ\",\n  \"submit\": \"ಸಲ್ಲಿಸಿ\"\n    },\n    \"status\": {\n      \"loading\": \"ಲೋಡ್ ಆಗುತ್ತಿದೆ...\",\n      \"error\": {\n        \"default\": \"ದೋಷ ಸಂಭವಿಸಿದೆ\",\n        \"serverConnection\": \"ಸರ್ವರ್‌ ಅನ್ನು ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"ಅಪ್ಲಿಕೇಶನ್‌ಗೆ ಪ್ರವೇಶಿಸಲು ಲಾಗಿನ್ ಮಾಡಿ\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ಇಮೇಲ್ ವಿಳಾಸ\",\n          \"required\": \"ಇಮೇಲ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"ಪಾಸ್‌ವರ್ಡ್\",\n          \"required\": \"ಪಾಸ್‌ವರ್ಡ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ\"\n        },\n        \"actions\": {\n          \"signin\": \"ಸೈನ್ ಇನ್ ಮಾಡಿ\"\n        },\n        \"alternativeText\": {\n          \"or\": \"ಅಥವಾ\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"ಸೈನ್ ಇನ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ\",\n        \"signin\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"oauthSignin\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"redirectUriMismatch\": \"ರೀಡೈರೆಕ್ಟ್ URI ಓಥ್ ಅಪ್ಲಿಕೇಶನ್ ಕಾನ್ಫಿಗರೇಶನ್‌ಗೆ ಹೊಂದಿಕೆಯಾಗುತ್ತಿಲ್ಲ\",\n        \"oauthCallback\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"oauthCreateAccount\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"emailCreateAccount\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"callback\": \"ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ\",\n        \"oauthAccountNotLinked\": \"ನಿಮ್ಮ ಗುರುತನ್ನು ದೃಢೀಕರಿಸಲು, ನೀವು ಮೊದಲು ಬಳಸಿದ ಅದೇ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ\",\n        \"emailSignin\": \"ಇಮೇಲ್ ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ\",\n        \"emailVerify\": \"ದಯವಿಟ್ಟು ನಿಮ್ಮ ಇಮೇಲ್ ಪರಿಶೀಲಿಸಿ, ಹೊಸ ಇಮೇಲ್ ಕಳುಹಿಸಲಾಗಿದೆ\",\n        \"credentialsSignin\": \"ಸೈನ್ ಇನ್ ವಿಫಲವಾಗಿದೆ. ನೀವು ಒದಗಿಸಿದ ವಿವರಗಳು ಸರಿಯಾಗಿವೆಯೇ ಎಂದು ಪರಿಶೀಲಿಸಿ\",\n        \"sessionRequired\": \"ಈ ಪುಟವನ್ನು ಪ್ರವೇಶಿಸಲು ದಯವಿಟ್ಟು ಸೈನ್ ಇನ್ ಮಾಡಿ\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} ನೊಂದಿಗೆ ಮುಂದುವರಿಸಿ\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಇಲ್ಲಿ ಟೈಪ್ ಮಾಡಿ...\",\n      \"actions\": {\n        \"send\": \"ಸಂದೇಶ ಕಳುಹಿಸಿ\",\n        \"stop\": \"ಕಾರ್ಯ ನಿಲ್ಲಿಸಿ\",\n        \"attachFiles\": \"ಫೈಲ್‌ಗಳನ್ನು ಲಗತ್ತಿಸಿ\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ಬಳಸಿ\",\n      \"headline\": \"ಮೆಚ್ಚಿನ ಸಂದೇಶಗಳು\",\n      \"remove\": \"ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ತೆಗೆದುಹಾಕಿ\",\n      \"empty\": {\n        \"title\": \"ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಉಳಿಸಲಾಗಿಲ್ಲ\",\n        \"description\": \"ಪ್ರಾಂಪ್ಟ್ ಕಳುಹಿಸಿ ಮತ್ತು ಅದಕ್ಕೆ ಸ್ಟಾರ್ ಮಾಡಿ ಅಥವಾ ಹಿಂದಿನ ಚಾಟ್‌ಗಳಿಂದ ಪ್ರಾಂಪ್ಟ್‌ಗೆ ಸ್ಟಾರ್ ಮಾಡಿ\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"ಉಪಕರಣಗಳು\",\n      \"changeTool\": \"ಉಪಕರಣವನ್ನು ಬದಲಿಸಿ\",\n      \"availableTools\": \"ಲಭ್ಯವಿರುವ ಉಪಕರಣಗಳು\"\n    },\n    \"speech\": {\n      \"start\": \"ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ\",\n      \"stop\": \"ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ\",\n      \"connecting\": \"ಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"ಫೈಲ್‌ಗಳನ್ನು ಇಲ್ಲಿ ಎಳೆದು ಬಿಡಿ\",\n      \"browse\": \"ಫೈಲ್‌ಗಳನ್ನು ಬ್ರೌಸ್ ಮಾಡಿ\",\n      \"sizeLimit\": \"ಮಿತಿ:\",\n      \"errors\": {\n        \"failed\": \"ಅಪ್‌ಲೋಡ್ ವಿಫಲವಾಗಿದೆ\",\n        \"cancelled\": \"ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಿ\",\n        \"removeAttachment\": \"ಅಟ್ಯಾಚ್‌ಮೆಂಟ್ ಅನ್ನು ತೆಗೆದುಹಾಕಿ\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"ಬಳಸುತ್ತಿರುವುದು\",\n        \"used\": \"ಬಳಸಲಾಗಿದೆ\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ\",\n          \"success\": \"ನಕಲಿಸಲಾಗಿದೆ!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"ಸಹಾಯಕವಾಗಿದೆ\",\n        \"negative\": \"ಸಹಾಯಕವಾಗಿಲ್ಲ\",\n        \"edit\": \"ಪ್ರತಿಕ್ರಿಯೆ ಸಂಪಾದಿಸಿ\",\n        \"dialog\": {\n          \"title\": \"ಕಾಮೆಂಟ್ ಸೇರಿಸಿ\",\n          \"submit\": \"ಪ್ರತಿಕ್ರಿಯೆ ಸಲ್ಲಿಸಿ\",\n          \"yourFeedback\": \"ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆ...\"\n        },\n        \"status\": {\n          \"updating\": \"ನವೀಕರಿಸಲಾಗುತ್ತಿದೆ\",\n          \"updated\": \"ಪ್ರತಿಕ್ರಿಯೆ ನವೀಕರಿಸಲಾಗಿದೆ\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"ಕೊನೆಯ ಇನ್‌ಪುಟ್‌ಗಳು\",\n      \"empty\": \"ಖಾಲಿಯಾಗಿದೆ...\",\n      \"show\": \"ಇತಿಹಾಸ ತೋರಿಸಿ\"\n    },\n    \"settings\": {\n      \"title\": \"ಸೆಟ್ಟಿಂಗ್‌ಗಳ ಪ್ಯಾನೆಲ್\",\n      \"customize\": \"ಈಗ ನಿಮ್ಮ ಚಾಟ್ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಕಸ್ಟಮೈಸ್ ಮಾಡಿ\"\n    },\n    \"watermark\": \"LLM ಗಳು ತಪ್ಪುಗಳನ್ನು ಮಾಡಬಹುದು. ಪ್ರಮುಖ ಮಾಹಿತಿಯನ್ನು ಪರಿಶೀಲಿಸುವುದನ್ನು ಪರಿಗಣಿಸಿ.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"ಹಿಂದಿನ ಸಂಭಾಷಣೆಗಳು\",\n      \"filters\": {\n        \"search\": \"ಹುಡುಕಿ\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"ಇಂದು\",\n        \"yesterday\": \"ನಿನ್ನೆ\",\n        \"previous7days\": \"ಹಿಂದಿನ 7 ದಿನಗಳು\",\n        \"previous30days\": \"ಹಿಂದಿನ 30 ದಿನಗಳು\"\n      },\n      \"empty\": \"ಯಾವುದೇ ಸಂಭಾಷಣೆಗಳು ಕಂಡುಬಂದಿಲ್ಲ\",\n      \"actions\": {\n        \"close\": \"ಪಕ್ಕದ ಪಟ್ಟಿ ಮುಚ್ಚಿ\",\n        \"open\": \"ಪಕ್ಕದ ಪಟ್ಟಿ ತೆರೆಯಿರಿ\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"ಶೀರ್ಷಿಕೆರಹಿತ ಸಂಭಾಷಣೆ\",\n      \"menu\": {\n          \"rename\": \"ಮರುಹೆಸರಿಸಿ\",\n          \"share\": \"ಹಂಚಿಕೊಳ್ಳಿ\",\n          \"delete\": \"ಅಳಿಸಿ\"\n        },\n      \"actions\": {\n        \"share\": {\n            \"title\": \"ಚಾಟ್‌ಗೆ ಲಿಂಕ್ ಹಂಚಿಕೊಳ್ಳಿ\",\n            \"button\": \"ಹಂಚಿಕೊಳ್ಳಿ\",\n            \"status\": {\n              \"copied\": \"ಲಿಂಕ್ ಪ್ರತಿಲಿಪಿ ಮಾಡಲಾಗಿದೆ\",\n              \"created\": \"ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲಾಗಿದೆ!\",\n              \"unshared\": \"ಈ ಸಂಭಾಷಣೆಗೆ ಹಂಚಿಕೆ ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ\"\n            },\n            \"error\": {\n              \"create\": \"ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲು ವಿಫಲವಾಗಿದೆ\",\n              \"unshare\": \"ಸಂಭಾಷಣೆ ಹಂಚಿಕೆಯನ್ನು ರದ್ದು ಮಾಡಲು ವಿಫಲವಾಗಿದೆ\"\n            }\n        },\n        \"delete\": {\n          \"title\": \"ಅಳಿಸುವಿಕೆಯನ್ನು ದೃಢೀಕರಿಸಿ\",\n          \"description\": \"ಇದು ಸಂಭಾಷಣೆಯನ್ನು ಹಾಗೂ ಅದರ ಸಂದೇಶಗಳು ಮತ್ತು ಅಂಶಗಳನ್ನು ಅಳಿಸುತ್ತದೆ. ಈ ಕ್ರಿಯೆಯನ್ನು ರದ್ದುಗೊಳಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ\",\n          \"success\": \"ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗಿದೆ\",\n          \"inProgress\": \"ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗುತ್ತಿದೆ\"\n        },\n        \"rename\": {\n          \"title\": \"ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಿ\",\n          \"description\": \"ಈ ಸಂಭಾಷಣೆಗೆ ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"ಹೆಸರು\",\n              \"placeholder\": \"ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ\"\n            }\n          },\n          \"success\": \"ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗಿದೆ!\",\n          \"inProgress\": \"ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗುತ್ತಿದೆ\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"ಸಂಭಾಷಣೆ\",\n      \"readme\": \"ಓದಿ\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"ಹೊಸ ಸಂಭಾಷಣೆ\",\n      \"dialog\": {\n        \"title\": \"ಹೊಸ ಸಂಭಾಷಣೆ ರಚಿಸಿ\",\n        \"description\": \"ಇದು ನಿಮ್ಮ ಪ್ರಸ್ತುತ ಸಂಭಾಷಣೆಯ ಇತಿಹಾಸವನ್ನು ಅಳಿಸುತ್ತದೆ. ನೀವು ಮುಂದುವರೆಯಲು ಬಯಸುವಿರಾ?\",\n        \"tooltip\": \"ಹೊಸ ಸಂಭಾಷಣೆ\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"ಸೆಟ್ಟಿಂಗ್‌ಗಳು\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API ಕೀಗಳು\",\n        \"logout\": \"ಲಾಗ್ ಔಟ್\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"ಅಗತ್ಯವಿರುವ API ಕೀಗಳು\",\n    \"description\": \"ಈ ಅಪ್ಲಿಕೇಶನ್ ಬಳಸಲು, ಈ ಕೆಳಗಿನ API ಕೀಗಳು ಅಗತ್ಯವಿರುತ್ತವೆ. ಕೀಗಳನ್ನು ನಿಮ್ಮ ಸಾಧನದ ಸ್ಥಳೀಯ ಸಂಗ್ರಹಣೆಯಲ್ಲಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ.\",\n    \"success\": {\n      \"saved\": \"ಯಶಸ್ವಿಯಾಗಿ ಉಳಿಸಲಾಗಿದೆ\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"ಚುನಾಯಿಸಿ...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"ದಿನಾಂಕವನ್ನು ಆಯ್ಕೆಮಾಡಿ\",\n        \"range\": \"ದಿನಾಂಕ ಶ್ರೇಣಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/ko.json",
    "content": "{\n    \"common\": {\n        \"actions\": {\n            \"cancel\": \"취소\",\n            \"confirm\": \"확인\",\n            \"continue\": \"계속\",\n            \"goBack\": \"뒤로 가기\",\n            \"reset\": \"초기화\",\n            \"submit\": \"제출\"\n        },\n        \"status\": {\n            \"loading\": \"로딩 중...\",\n            \"error\": {\n                \"default\": \"오류가 발생했습니다\",\n                \"serverConnection\": \"서버에 연결할 수 없습니다\"\n            }\n        }\n    },\n    \"auth\": {\n        \"login\": {\n            \"title\": \"앱에 접근하려면 로그인하세요\",\n            \"form\": {\n                \"email\": {\n                    \"label\": \"이메일 주소\",\n                    \"required\": \"이메일은 필수 입력 항목입니다\",\n                    \"placeholder\": \"me@example.com\"\n                },\n                \"password\": {\n                    \"label\": \"비밀번호\",\n                    \"required\": \"비밀번호는 필수 입력 항목입니다\"\n                },\n                \"actions\": {\n                    \"signin\": \"로그인\"\n                },\n                \"alternativeText\": {\n                    \"or\": \"또는\"\n                }\n            },\n            \"errors\": {\n                \"default\": \"로그인할 수 없습니다\",\n                \"signin\": \"다른 계정으로 로그인해보세요\",\n                \"oauthSignin\": \"다른 계정으로 로그인해보세요\",\n                \"redirectUriMismatch\": \"리다이렉트 URI가 OAuth 앱 설정과 일치하지 않습니다\",\n                \"oauthCallback\": \"다른 계정으로 로그인해보세요\",\n                \"oauthCreateAccount\": \"다른 계정으로 로그인해보세요\",\n                \"emailCreateAccount\": \"다른 계정으로 로그인해보세요\",\n                \"callback\": \"다른 계정으로 로그인해보세요\",\n                \"oauthAccountNotLinked\": \"신원을 확인하려면 원래 사용했던 계정으로 로그인하세요\",\n                \"emailSignin\": \"이메일을 보낼 수 없습니다\",\n                \"emailVerify\": \"이메일을 확인해주세요. 새로운 이메일이 발송되었습니다\",\n                \"credentialsSignin\": \"로그인 실패. 제공한 정보가 올바른지 확인하세요\",\n                \"sessionRequired\": \"이 페이지에 접근하려면 로그인해주세요\"\n            }\n        },\n        \"provider\": {\n            \"continue\": \"{{provider}}로 계속하기\"\n        }\n    },\n    \"chat\": {\n        \"input\": {\n            \"placeholder\": \"여기에 메시지를 입력하세요...\",\n            \"actions\": {\n                \"send\": \"메시지 보내기\",\n                \"stop\": \"작업 중지\",\n                \"attachFiles\": \"파일 첨부\"\n            }\n        },\n        \"favorites\": {\n          \"use\": \"즐겨찾기 메시지 사용\",\n          \"headline\": \"즐겨찾기 메시지\",\n          \"remove\": \"즐겨찾기 제거\",\n          \"empty\": {\n            \"title\": \"저장된 프롬프트가 아직 없습니다\",\n            \"description\": \"프롬프트를 보내고 별표를 추가하거나 이전 대화에서 프롬프트에 별표를 추가하세요\"\n          }\n        },\n        \"commands\": {\n            \"button\": \"도구\",\n            \"changeTool\": \"도구 변경\",\n            \"availableTools\": \"사용 가능한 도구\"\n        },\n        \"speech\": {\n            \"start\": \"녹음 시작\",\n            \"stop\": \"녹음 중지\",\n            \"connecting\": \"연결 중\"\n        },\n        \"fileUpload\": {\n            \"dragDrop\": \"여기에 파일을 드래그 앤 드롭하세요\",\n            \"browse\": \"파일 찾아보기\",\n            \"sizeLimit\": \"제한:\",\n            \"errors\": {\n                \"failed\": \"업로드 실패\",\n                \"cancelled\": \"업로드 취소:\"\n            },\n            \"actions\": {\n                \"cancelUpload\": \"업로드 취소\",\n                \"removeAttachment\": \"첨부 파일 제거\"\n            }\n        },\n        \"messages\": {\n            \"status\": {\n                \"using\": \"사용 중\",\n                \"used\": \"사용됨\"\n            },\n            \"actions\": {\n                \"copy\": {\n                    \"button\": \"클립보드로 복사\",\n                    \"success\": \"복사되었습니다!\"\n                }\n            },\n            \"feedback\": {\n                \"positive\": \"도움이 되었음\",\n                \"negative\": \"도움이 되지 않음\",\n                \"edit\": \"피드백 수정\",\n                \"dialog\": {\n                    \"title\": \"댓글 추가\",\n                    \"submit\": \"피드백 제출\",\n                    \"yourFeedback\": \"귀하의 피드백...\"\n                },\n                \"status\": {\n                    \"updating\": \"업데이트 중\",\n                    \"updated\": \"피드백이 업데이트되었습니다\"\n                }\n            }\n        },\n        \"history\": {\n            \"title\": \"최근 입력\",\n            \"empty\": \"비어 있습니다...\",\n            \"show\": \"기록 표시\"\n        },\n        \"settings\": {\n            \"title\": \"설정 패널\",\n            \"customize\": \"여기에서 채팅 설정을 사용자 지정하세요\"\n        },\n        \"watermark\": \"LLM은 실수할 수 있습니다. 중요한 정보는 확인하세요.\"\n    },\n    \"threadHistory\": {\n        \"sidebar\": {\n            \"title\": \"이전 채팅\",\n            \"filters\": {\n                \"search\": \"검색\",\n                \"placeholder\": \"대화 검색...\"\n            },\n            \"timeframes\": {\n                \"today\": \"오늘\",\n                \"yesterday\": \"어제\",\n                \"previous7days\": \"지난 7일\",\n                \"previous30days\": \"지난 30일\"\n            },\n            \"empty\": \"스레드를 찾을 수 없습니다\",\n            \"actions\": {\n                \"close\": \"사이드바 닫기\",\n                \"open\": \"사이드바 열기\"\n            }\n        },\n        \"thread\": {\n            \"untitled\": \"제목 없는 대화\",\n            \"menu\": {\n                \"rename\": \"이름 변경\",\n                \"share\": \"공유\",\n                \"delete\": \"삭제\"\n            },\n            \"actions\": {\n                \"share\": {\n                    \"title\": \"채팅 링크 공유\",\n                    \"button\": \"공유\",\n                    \"status\": {\n                        \"copied\": \"링크 복사됨\",\n                        \"created\": \"공유 링크가 생성되었습니다!\",\n                        \"unshared\": \"이 스레드의 공유가 비활성화되었습니다\"\n                    },\n                    \"error\": {\n                        \"create\": \"공유 링크 생성 실패\",\n                        \"unshare\": \"스레드 공유 해제 실패\"\n                    }\n                },\n                \"delete\": {\n                    \"title\": \"삭제 확인\",\n                    \"description\": \"이렇게 하면 스레드와 그 메시지 및 요소가 삭제됩니다. 이 작업은 취소할 수 없습니다\",\n                    \"success\": \"채팅이 삭제되었습니다\",\n                    \"inProgress\": \"채팅 삭제 중\"\n                },\n                \"rename\": {\n                    \"title\": \"스레드 이름 변경\",\n                    \"description\": \"이 스레드의 새 이름을 입력하세요\",\n                    \"form\": {\n                        \"name\": {\n                            \"label\": \"이름\",\n                            \"placeholder\": \"새 이름 입력\"\n                        }\n                    },\n                    \"success\": \"스레드 이름이 변경되었습니다!\",\n                    \"inProgress\": \"스레드 이름 변경 중\"\n                }\n            }\n        }\n    },\n    \"navigation\": {\n        \"header\": {\n            \"chat\": \"채팅\",\n            \"readme\": \"읽어보기\",\n            \"theme\": {\n                \"light\": \"밝은 테마\",\n                \"dark\": \"어두운 테마\",\n                \"system\": \"시스템 따라가기\"\n            }\n        },\n        \"newChat\": {\n            \"button\": \"새 채팅\",\n            \"dialog\": {\n                \"title\": \"새 채팅 만들기\",\n                \"description\": \"이렇게 하면 현재 채팅 기록이 지워집니다. 계속하시겠습니까?\",\n                \"tooltip\": \"새 채팅\"\n            }\n        },\n        \"user\": {\n            \"menu\": {\n                \"settings\": \"설정\",\n                \"settingsKey\": \"S\",\n                \"apiKeys\": \"API 키\",\n                \"logout\": \"로그아웃\"\n            }\n        }\n    },\n    \"apiKeys\": {\n        \"title\": \"필요한 API 키\",\n        \"description\": \"이 앱을 사용하려면 다음 API 키가 필요합니다. 키는 기기의 로컬 저장소에 저장됩니다.\",\n        \"success\": {\n            \"saved\": \"성공적으로 저장되었습니다\"\n        }\n    },\n    \"alerts\": {\n        \"info\": \"정보\",\n        \"note\": \"참고\",\n        \"tip\": \"팁\",\n        \"important\": \"중요\",\n        \"warning\": \"경고\",\n        \"caution\": \"주의\",\n        \"debug\": \"디버그\",\n        \"example\": \"예시\",\n        \"success\": \"성공\",\n        \"help\": \"도움말\",\n        \"idea\": \"아이디어\",\n        \"pending\": \"대기 중\",\n        \"security\": \"보안\",\n        \"beta\": \"베타\",\n        \"best-practice\": \"모범 사례\"\n    },\n    \"components\": {\n        \"MultiSelectInput\": {\n            \"placeholder\": \"선택...\"\n        }\n    }\n}"
  },
  {
    "path": "backend/chainlit/translations/ml.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"റദ്ദാക്കുക\",\n      \"confirm\": \"സ്ഥിരീകരിക്കുക\",\n      \"continue\": \"തുടരുക\",\n      \"goBack\": \"തിരികെ പോകുക\",\n      \"reset\": \"പുനഃസജ്ജമാക്കുക\",\n      \"submit\": \"സമർപ്പിക്കുക\"\n    },\n    \"status\": {\n      \"loading\": \"ലോഡ് ചെയ്യുന്നു...\",\n      \"error\": {\n        \"default\": \"ഒരു പിശക് സംഭവിച്ചു\",\n        \"serverConnection\": \"സെർവറുമായി ബന്ധപ്പെടാൻ കഴിഞ്ഞില്ല\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"ആപ്പ് ഉപയോഗിക്കാൻ ലോഗിൻ ചെയ്യുക\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ഇമെയിൽ വിലാസം\",\n          \"required\": \"ഇമെയിൽ ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"പാസ്‌വേഡ്\",\n          \"required\": \"പാസ്‌വേഡ് ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്\"\n        },\n        \"actions\": {\n          \"signin\": \"സൈൻ ഇൻ\"\n        },\n        \"alternativeText\": {\n          \"or\": \"അല്ലെങ്കിൽ\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"സൈൻ ഇൻ ചെയ്യാൻ കഴിയുന്നില്ല\",\n        \"signin\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"oauthSignin\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"redirectUriMismatch\": \"റീഡയറക്ട് URI oauth ആപ്പ് കോൺഫിഗറേഷനുമായി പൊരുത്തപ്പെടുന്നില്ല\",\n        \"oauthCallback\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"oauthCreateAccount\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"emailCreateAccount\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"callback\": \"മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക\",\n        \"oauthAccountNotLinked\": \"നിങ്ങളുടെ വ്യക്തിത്വം സ്ഥിരീകരിക്കാൻ, ആദ്യം ഉപയോഗിച്ച അതേ അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യുക\",\n        \"emailSignin\": \"ഇമെയിൽ അയയ്ക്കാൻ കഴിഞ്ഞില്ല\",\n        \"emailVerify\": \"നിങ്ങളുടെ ഇമെയിൽ പരിശോധിക്കുക, ഒരു പുതിയ ഇമെയിൽ അയച്ചിട്ടുണ്ട്\",\n        \"credentialsSignin\": \"സൈൻ ഇൻ പരാജയപ്പെട്ടു. നിങ്ങൾ നൽകിയ വിവരങ്ങൾ ശരിയാണെന്ന് പരിശോധിക്കുക\",\n        \"sessionRequired\": \"ഈ പേജ് ആക്സസ് ചെയ്യാൻ ദയവായി സൈൻ ഇൻ ചെയ്യുക\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} ഉപയോഗിച്ച് തുടരുക\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"നിങ്ങളുടെ സന്ദേശം ഇവിടെ ടൈപ്പ് ചെയ്യുക...\",\n      \"actions\": {\n        \"send\": \"സന്ദേശം അയയ്ക്കുക\",\n        \"stop\": \"ടാസ്ക് നിർത്തുക\",\n        \"attachFiles\": \"ഫയലുകൾ അറ്റാച്ച് ചെയ്യുക\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"പ്രിയപ്പെട്ട സന്ദേശം ഉപയോഗിക്കുക\",\n      \"headline\": \"പ്രിയപ്പെട്ട സന്ദേശങ്ങൾ\",\n      \"remove\": \"ഇഷ്ടപ്പെട്ടത് നീക്കം ചെയ്യുക\",\n      \"empty\": {\n        \"title\": \"ഇതുവരെ സംരക്ഷിച്ച പ്രോംപ്റ്റുകളൊന്നുമില്ല\",\n        \"description\": \"ഒരു പ്രോംപ്റ്റ് അയച്ച് അതിന് സ്റ്റാർ ചെയ്തുകൊണ്ട് ആരംഭിക്കുക അല്ലെങ്കിൽ മുൻ ചാറ്റുകളിൽ നിന്ന് ഒരു പ്രോംപ്റ്റിന് സ്റ്റാർ ചെയ്യുക\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"ഉപകരണങ്ങൾ\",\n      \"changeTool\": \"ഉപകരണം മാറ്റുക\",\n      \"availableTools\": \"ലഭ്യമായ ഉപകരണങ്ങൾ\"\n    },\n    \"speech\": {\n      \"start\": \"റെക്കോർഡിംഗ് ആരംഭിക്കുക\",\n      \"stop\": \"റെക്കോർഡിംഗ് നിർത്തുക\",\n      \"connecting\": \"ബന്ധിപ്പിക്കുന്നു\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"ഫയലുകൾ ഇവിടെ വലിച്ചിടുക\",\n      \"browse\": \"ഫയലുകൾ തിരയുക\",\n      \"sizeLimit\": \"പരിധി:\",\n      \"errors\": {\n        \"failed\": \"അപ്‌ലോഡ് പരാജയപ്പെട്ടു\",\n        \"cancelled\": \"അപ്‌ലോഡ് റദ്ദാക്കി\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"അപ്‌ಲോഡ് റദ്ദുചെയ്യുക\",\n        \"removeAttachment\": \"അറ്റാച്ച്‌മെന്റ് നീക്കം ചെയ്യുക\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"ഉപയോഗിക്കുന്നു\",\n        \"used\": \"ഉപയോഗിച്ചു\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തുക\",\n          \"success\": \"പകർത്തി!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"സഹായകരം\",\n        \"negative\": \"സഹായകരമല്ല\",\n        \"edit\": \"ഫീഡ്ബാക്ക് എഡിറ്റ് ചെയ്യുക\",\n        \"dialog\": {\n          \"title\": \"ഒരു കമന്റ് ചേർക്കുക\",\n          \"submit\": \"ഫീഡ്ബാക്ക് സമർപ്പിക്കുക\",\n          \"yourFeedback\": \"നിങ്ങളുടെ പ്രതികരണം...\"\n        },\n        \"status\": {\n          \"updating\": \"അപ്ഡേറ്റ് ചെയ്യുന്നു\",\n          \"updated\": \"ഫീഡ്ബാക്ക് അപ്ഡേറ്റ് ചെയ്തു\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"അവസാന ഇൻപുട്ടുകൾ\",\n      \"empty\": \"ഒന്നുമില്ല...\",\n      \"show\": \"ഹിസ്റ്ററി കാണിക്കുക\"\n    },\n    \"settings\": {\n      \"title\": \"ക്രമീകരണങ്ങൾ പാനൽ\",\n      \"customize\": \"ഈ സമയം നിങ്ങളുടെ ചാറ്റ് ക്രമീകരണങ്ങൾ കസ്റ്റമൈസ് ചെയ്യുക\"\n    },\n    \"watermark\": \"LLM കൾക്ക് തെറ്റുകൾ വരുത്താം. പ്രധാനപ്പെട്ട വിവരങ്ങൾ പരിശോധിക്കുന്നത് പരിഗണിക്കുക.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"മുൻ ചാറ്റുകൾ\",\n      \"filters\": {\n        \"search\": \"തിരയുക\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"ഇന്ന്\",\n        \"yesterday\": \"ഇന്നലെ\",\n        \"previous7days\": \"കഴിഞ്ഞ 7 ദിവസം\",\n        \"previous30days\": \"കഴിഞ്ഞ 30 ദിവസം\"\n      },\n      \"empty\": \"ത്രെഡുകൾ കണ്ടെത്തിയില്ല\",\n      \"actions\": {\n        \"close\": \"സൈഡ്ബാർ അടയ്ക്കുക\",\n        \"open\": \"സൈഡ്ബാർ തുറക്കുക\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"പേരില്ലാത്ത സംഭാഷണം\",\n      \"menu\": {\n          \"rename\": \"പേര് മാറ്റുക\",\n          \"share\": \"പങ്കിടുക\",\n          \"delete\": \"ഡിലീറ്റ്\"\n        },\n      \"actions\": {\n        \"share\": {\n            \"title\": \"ചാറ്റിലേക്ക് ലിങ്ക് പങ്കിടുക\",\n            \"button\": \"പങ്കിടുക\",\n            \"status\": {\n              \"copied\": \"ലിങ്ക് പകർത്തി\",\n              \"created\": \"പങ്കിടൽ ലിങ്ക് സൃഷ്ടിച്ചു!\",\n              \"unshared\": \"ഈ ത്രെഡിനായി പങ്കിടൽ അപ്രാപ്തമാക്കി\"\n            },\n            \"error\": {\n              \"create\": \"പങ്കിടൽ ലിങ്ക് സൃഷ്ടിക്കൽ പരാജയപ്പെട്ടു\",\n              \"unshare\": \"ത്രെഡ് പങ്കിടൽ അവസാനിപ്പിക്കൽ പരാജയപ്പെട്ടു\"\n            }\n        },\n        \"delete\": {\n          \"title\": \"ഡിലീറ്റ് സ്ഥിരീകരിക്കുക\",\n          \"description\": \"ഇത് ത്രെഡും അതിന്റെ സന്ദേശങ്ങളും ഘടകങ്ങളും ഡിലീറ്റ് ചെയ്യും. ഈ പ്രവർത്തി പഴയപടിയാക്കാൻ കഴിയില്ല\",\n          \"success\": \"ചാറ്റ് ഡിലീറ്റ് ചെയ്തു\",\n          \"inProgress\": \"ചാറ്റ് ഡിലീറ്റ് ചെയ്യുന്നു\"\n        },\n        \"rename\": {\n          \"title\": \"ത്രെഡ് പുനർനാമകരണം ചെയ്യുക\",\n          \"description\": \"ഈ ത്രെഡിന് ഒരു പുതിയ പേര് നൽകുക\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"പേര്\",\n              \"placeholder\": \"പുതിയ പേര് നൽകുക\"\n            }\n          },\n          \"success\": \"ത്രെഡ് പുനർനാമകരണം ചെയ്തു!\",\n          \"inProgress\": \"ത്രെഡ് പുനർനാമകരണം ചെയ്യുന്നു\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"ചാറ്റ്\",\n      \"readme\": \"വായിക്കുക\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"പുതിയ ചാറ്റ്\",\n      \"dialog\": {\n        \"title\": \"പുതിയ ചാറ്റ് സൃഷ്ടിക്കുക\",\n        \"description\": \"ഇത് നിങ്ങളുടെ നിലവിലെ ചാറ്റ് ഹിസ്റ്ററി മായ്ക്കും. തുടരാൻ താൽപ്പര്യമുണ്ടോ?\",\n        \"tooltip\": \"പുതിയ ചാറ്റ്\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"ക്രമീകരണങ്ങൾ\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API കീകൾ\",\n        \"logout\": \"ലോഗ്ഔട്ട്\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"ആവശ്യമായ API കീകൾ\",\n    \"description\": \"ഈ ആപ്പ് ഉപയോഗിക്കാൻ, താഴെപ്പറയുന്ന API കീകൾ ആവശ്യമാണ്. കീകൾ നിങ്ങളുടെ ഉപകരണത്തിന്റെ ലോക്കൽ സ്റ്റോറേജിൽ സംഭരിക്കപ്പെടുന്നു.\",\n    \"success\": {\n      \"saved\": \"വിജയകരമായി സംരക്ഷിച്ചു\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"ചൂണ്ടിക്കാണിക്കുക...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"തീയതി തിരഞ്ഞെടുക്കുക\",\n        \"range\": \"തീയതി ശ്രേണി തിരഞ്ഞെടുക്കുക\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/mr.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"रद्द करा\",\n      \"confirm\": \"पुष्टी करा\",\n      \"continue\": \"पुढे जा\",\n      \"goBack\": \"मागे जा\",\n      \"reset\": \"रीसेट करा\",\n      \"submit\": \"सबमिट करा\"\n    },\n    \"status\": {\n      \"loading\": \"लोड करत आहे...\",\n      \"error\": {\n        \"default\": \"एक त्रुटी आली\",\n        \"serverConnection\": \"सर्व्हरशी कनेक्ट होऊ शकले नाही\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"अॅप वापरण्यासाठी लॉगिन करा\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ईमेल पत्ता\",\n          \"required\": \"ईमेल आवश्यक आहे\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"पासवर्ड\",\n          \"required\": \"पासवर्ड आवश्यक आहे\"\n        },\n        \"actions\": {\n          \"signin\": \"साइन इन करा\"\n        },\n        \"alternativeText\": {\n          \"or\": \"किंवा\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"साइन इन करू शकत नाही\",\n        \"signin\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"oauthSignin\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"redirectUriMismatch\": \"रीडायरेक्ट URI ओथ अॅप कॉन्फिगरेशनशी जुळत नाही\",\n        \"oauthCallback\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"oauthCreateAccount\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"emailCreateAccount\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"callback\": \"वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा\",\n        \"oauthAccountNotLinked\": \"तुमची ओळख पटवण्यासाठी, मूळ वापरलेल्या खात्यानेच साइन इन करा\",\n        \"emailSignin\": \"ईमेल पाठवू शकले नाही\",\n        \"emailVerify\": \"कृपया तुमचा ईमेल तपासा, नवीन ईमेल पाठवला गेला आहे\",\n        \"credentialsSignin\": \"साइन इन अयशस्वी. तुम्ही दिलेली माहिती योग्य आहे का ते तपासा\",\n        \"sessionRequired\": \"या पृष्ठावर प्रवेश करण्यासाठी कृपया साइन इन करा\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} सह पुढे जा\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"तुमचा संदेश येथे टाइप करा...\",\n      \"actions\": {\n        \"send\": \"संदेश पाठवा\",\n        \"stop\": \"कार्य थांबवा\",\n        \"attachFiles\": \"फाइल्स जोडा\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"रेकॉर्डिंग सुरू करा\",\n      \"stop\": \"रेकॉर्डिंग थांबवा\",\n      \"connecting\": \"कनेक्ट करत आहे\"\n    },\n    \"favorites\": {\n      \"use\": \"आवडता संदेश वापरा\",\n      \"headline\": \"आवडते संदेश\",\n      \"remove\": \"आवडता संदेश काढा\",\n      \"empty\": {\n        \"title\": \"अद्याप कोणतेही प्रॉम्प्ट जतन केलेले नाहीत\",\n        \"description\": \"एक प्रॉम्प्ट पाठवून आणि त्यावर स्टार करून सुरुवात करा किंवा मागील चॅटमधून प्रॉम्प्टवर स्टार करा\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"साधने\",\n      \"changeTool\": \"साधन बदला\",\n      \"availableTools\": \"उपलब्ध साधने\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"फाइल्स येथे ड्रॅग आणि ड्रॉप करा\",\n      \"browse\": \"फाइल्स ब्राउझ करा\",\n      \"sizeLimit\": \"मर्यादा:\",\n      \"errors\": {\n        \"failed\": \"अपलोड अयशस्वी\",\n        \"cancelled\": \"यांचे अपलोड रद्द केले\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"अपलोड रद्द करा\",\n        \"removeAttachment\": \"अटॅचमेंट काढा\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"वापरत आहे\",\n        \"used\": \"वापरले\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"क्लिपबोर्डवर कॉपी करा\",\n          \"success\": \"कॉपी केले!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"उपयुक्त\",\n        \"negative\": \"उपयुक्त नाही\",\n        \"edit\": \"फीडबॅक संपादित करा\",\n        \"dialog\": {\n          \"title\": \"टिप्पणी जोडा\",\n          \"submit\": \"फीडबॅक सबमिट करा\",\n          \"yourFeedback\": \"तुमची प्रतिक्रिया...\"\n        },\n        \"status\": {\n          \"updating\": \"अपडेट करत आहे\",\n          \"updated\": \"फीडबॅक अपडेट केले\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"शेवटचे इनपुट\",\n      \"empty\": \"रिकामे आहे...\",\n      \"show\": \"इतिहास दाखवा\"\n    },\n    \"settings\": {\n      \"title\": \"सेटिंग्ज पॅनल\",\n      \"customize\": \"या वेळी तुमच्या चॅट सेटिंग्ज कस्टमाइझ करा\"\n    },\n    \"watermark\": \"LLM चुका करू शकतात. महत्त्वाची माहिती तपासण्याचा विचार करा.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"मागील चॅट्स\",\n      \"filters\": {\n        \"search\": \"शोधा\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"आज\",\n        \"yesterday\": \"काल\",\n        \"previous7days\": \"मागील 7 दिवस\",\n        \"previous30days\": \"मागील 30 दिवस\"\n      },\n      \"empty\": \"कोणतेही थ्रेड सापडले नाहीत\",\n      \"actions\": {\n        \"close\": \"साइडबार बंद करा\",\n        \"open\": \"साइडबार उघडा\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"शीर्षकविरहित संभाषण\",\n      \"menu\": {\n          \"rename\": \"नाव बदला\",\n          \"share\": \"शेअर करा\",\n          \"delete\": \"हटवा\"\n        },\n      \"actions\": {\n        \"share\": {\n            \"title\": \"चॅटचा दुवा शेअर करा\",\n            \"button\": \"शेअर करा\",\n            \"status\": {\n              \"copied\": \"दुवा कॉपी केला\",\n              \"created\": \"शेअर दुवा तयार झाला!\",\n              \"unshared\": \"या थ्रेडसाठी शेअरिंग अक्षम केले\"\n            },\n            \"error\": {\n              \"create\": \"शेअर दुवा तयार करण्यात अपयश\",\n              \"unshare\": \"थ्रेडचे शेअरिंग थांबवण्यात अपयश\"\n            }\n        },\n        \"delete\": {\n          \"title\": \"हटविण्याची पुष्टी करा\",\n          \"description\": \"हे थ्रेड आणि त्याचे संदेश व घटक हटवेल. ही क्रिया पूर्ववत केली जाऊ शकत नाही\",\n          \"success\": \"चॅट हटवला\",\n          \"inProgress\": \"चॅट हटवत आहे\"\n        },\n        \"rename\": {\n          \"title\": \"थ्रेडचे नाव बदला\",\n          \"description\": \"या थ्रेडसाठी नवीन नाव प्रविष्ट करा\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"नाव\",\n              \"placeholder\": \"नवीन नाव प्रविष्ट करा\"\n            }\n          },\n          \"success\": \"थ्रेडचे नाव बदलले!\",\n          \"inProgress\": \"थ्रेडचे नाव बदलत आहे\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"चॅट\",\n      \"readme\": \"वाचा\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"नवीन चॅट\",\n      \"dialog\": {\n        \"title\": \"नवीन चॅट तयार करा\",\n        \"description\": \"हे तुमचा सध्याचा चॅट इतिहास साफ करेल. तुम्हाला खात्री आहे की तुम्ही पुढे जाऊ इच्छिता?\",\n        \"tooltip\": \"नवीन चॅट\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"सेटिंग्ज\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API कीज\",\n        \"logout\": \"लॉगआउट\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"आवश्यक API कीज\",\n    \"description\": \"हे अॅप वापरण्यासाठी खालील API कीज आवश्यक आहेत. कीज तुमच्या डिव्हाइसच्या लोकल स्टोरेजमध्ये साठवल्या जातात.\",\n    \"success\": {\n      \"saved\": \"यशस्वीरित्या जतन केले\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"चुनें...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"तारीख निवडा\",\n        \"range\": \"तारीख श्रेणी निवडा\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/nl.json",
    "content": "{\n    \"common\": {\n      \"actions\": {\n        \"cancel\": \"Annuleren\",\n        \"confirm\": \"Bevestigen\",\n        \"continue\": \"Doorgaan\",\n        \"goBack\": \"Terug\",\n        \"reset\": \"Herstellen\",\n        \"submit\": \"Versturen\"\n      },\n      \"status\": {\n        \"loading\": \"Laden...\",\n        \"error\": {\n          \"default\": \"Er is een fout opgetreden\",\n          \"serverConnection\": \"Kon geen verbinding maken met de server\"\n        }\n      }\n    },\n    \"auth\": {\n      \"login\": {\n        \"title\": \"Inloggen om toegang te krijgen tot de app\",\n        \"form\": {\n          \"email\": {\n            \"label\": \"E-mailadres\",\n            \"required\": \"e-mail is een verplicht veld\",\n          \"placeholder\": \"me@example.com\"\n          },\n          \"password\": {\n            \"label\": \"Wachtwoord\",\n            \"required\": \"wachtwoord is een verplicht veld\"\n          },\n          \"actions\": {\n            \"signin\": \"Inloggen\"\n          },\n          \"alternativeText\": {\n            \"or\": \"OF\"\n          }\n        },\n        \"errors\": {\n          \"default\": \"Kan niet inloggen\",\n          \"signin\": \"Probeer in te loggen met een ander account\",\n          \"oauthSignin\": \"Probeer in te loggen met een ander account\",\n          \"redirectUriMismatch\": \"De redirect URI komt niet overeen met de oauth app configuratie\",\n          \"oauthCallback\": \"Probeer in te loggen met een ander account\",\n          \"oauthCreateAccount\": \"Probeer in te loggen met een ander account\",\n          \"emailCreateAccount\": \"Probeer in te loggen met een ander account\",\n          \"callback\": \"Probeer in te loggen met een ander account\",\n          \"oauthAccountNotLinked\": \"Om je identiteit te bevestigen, log in met hetzelfde account dat je oorspronkelijk hebt gebruikt\",\n          \"emailSignin\": \"De e-mail kon niet worden verzonden\",\n          \"emailVerify\": \"Verifieer je e-mail, er is een nieuwe e-mail verzonden\",\n          \"credentialsSignin\": \"Inloggen mislukt. Controleer of de ingevoerde gegevens correct zijn\",\n          \"sessionRequired\": \"Log in om toegang te krijgen tot deze pagina\"\n        }\n      },\n      \"provider\": {\n        \"continue\": \"Doorgaan met {{provider}}\"\n      }\n    },\n    \"chat\": {\n      \"input\": {\n        \"placeholder\": \"Typ hier je bericht...\",\n        \"actions\": {\n          \"send\": \"Bericht versturen\",\n          \"stop\": \"Taak stoppen\",\n          \"attachFiles\": \"Bestanden bijvoegen\"\n        }\n      },\n      \"speech\": {\n        \"start\": \"Start opname\",\n        \"stop\": \"Stop opname\",\n        \"connecting\": \"Verbinden\"\n      },\n      \"fileUpload\": {\n        \"dragDrop\": \"Sleep bestanden hierheen\",\n        \"browse\": \"Bestanden zoeken\",\n        \"sizeLimit\": \"Limiet:\",\n        \"errors\": {\n          \"failed\": \"Uploaden mislukt\",\n          \"cancelled\": \"Upload geannuleerd van\"\n        },\n        \"actions\": {\n          \"cancelUpload\": \"Annuleer upload\",\n          \"removeAttachment\": \"Verwijder bijlage\"\n        }\n      },\n      \"favorites\": {\n        \"use\": \"Gebruik een favoriet bericht\",\n        \"headline\": \"Favoriete berichten\",\n        \"remove\": \"Verwijder favoriet\",\n        \"empty\": {\n          \"title\": \"Nog geen opgeslagen prompts\",\n          \"description\": \"Begin door een prompt te versturen en voeg deze toe aan favorieten of voeg een prompt uit eerdere chats toe\"\n        }\n      },\n      \"commands\": {\n        \"button\": \"Hulpmiddelen\",\n        \"changeTool\": \"Wijzig hulpmiddel\",\n        \"availableTools\": \"Beschikbare hulpmiddelen\"\n      },\n      \"messages\": {\n        \"status\": {\n          \"using\": \"In gebruik\",\n          \"used\": \"Gebruikt\"\n        },\n        \"actions\": {\n          \"copy\": {\n            \"button\": \"Kopiëren naar klembord\",\n            \"success\": \"Gekopieerd!\"\n          }\n        },\n        \"feedback\": {\n          \"positive\": \"Nuttig\",\n          \"negative\": \"Niet nuttig\",\n          \"edit\": \"Feedback bewerken\",\n          \"dialog\": {\n            \"title\": \"Voeg een opmerking toe\",\n            \"submit\": \"Feedback versturen\",\n            \"yourFeedback\": \"Je feedback...\"\n          },\n          \"status\": {\n            \"updating\": \"Bijwerken\",\n            \"updated\": \"Feedback bijgewerkt\"\n          }\n        }\n      },\n      \"history\": {\n        \"title\": \"Laatste invoer\",\n        \"empty\": \"Zo leeg...\",\n        \"show\": \"Toon geschiedenis\"\n      },\n      \"settings\": {\n        \"title\": \"Instellingenpaneel\",\n        \"customize\": \"Pas hier je chatinstellingen aan\"\n      },\n      \"watermark\": \"LLM's kunnen fouten maken. Overweeg het controleren van belangrijke informatie.\"\n    },\n    \"threadHistory\": {\n      \"sidebar\": {\n        \"title\": \"Eerdere chats\",\n        \"filters\": {\n          \"search\": \"Zoeken\",\n          \"placeholder\": \"Search conversations...\"\n        },\n        \"timeframes\": {\n          \"today\": \"Vandaag\",\n          \"yesterday\": \"Gisteren\",\n          \"previous7days\": \"Afgelopen 7 dagen\",\n          \"previous30days\": \"Afgelopen 30 dagen\"\n        },\n        \"empty\": \"Geen gesprekken gevonden\",\n        \"actions\": {\n          \"close\": \"Zijbalk sluiten\",\n          \"open\": \"Zijbalk openen\"\n        }\n      },\n      \"thread\": {\n        \"untitled\": \"Naamloos gesprek\",\n        \"menu\": {\n          \"rename\": \"Hernoemen\",\n          \"share\": \"Delen\",\n          \"delete\": \"Verwijderen\"\n        },\n        \"actions\": {\n          \"share\": {\n            \"title\": \"Deel link naar chat\",\n            \"button\": \"Delen\",\n            \"status\": {\n              \"copied\": \"Link gekopieerd\",\n              \"created\": \"Deellink gemaakt!\",\n              \"unshared\": \"Delen uitgeschakeld voor dit gesprek\"\n            },\n            \"error\": {\n              \"create\": \"Aanmaken van deellink mislukt\",\n              \"unshare\": \"Delen van gesprek stoppen mislukt\"\n            }\n          },\n          \"delete\": {\n            \"title\": \"Verwijdering bevestigen\",\n            \"description\": \"Dit zal het gesprek en bijbehorende berichten en elementen verwijderen. Deze actie kan niet ongedaan worden gemaakt\",\n            \"success\": \"Chat verwijderd\",\n            \"inProgress\": \"Chat verwijderen\"\n          },\n          \"rename\": {\n            \"title\": \"Gesprek hernoemen\",\n            \"description\": \"Voer een nieuwe naam in voor dit gesprek\",\n            \"form\": {\n              \"name\": {\n                \"label\": \"Naam\",\n                \"placeholder\": \"Voer nieuwe naam in\"\n              }\n            },\n            \"success\": \"Gesprek hernoemd!\",\n            \"inProgress\": \"Gesprek hernoemen\"\n          }\n        }\n      }\n    },\n    \"navigation\": {\n      \"header\": {\n        \"chat\": \"Chat\",\n        \"readme\": \"Leesmij\",\n        \"theme\": {\n          \"light\": \"Light Theme\",\n          \"dark\": \"Dark Theme\",\n          \"system\": \"Follow System\"\n        }\n      },\n      \"newChat\": {\n        \"button\": \"Nieuwe chat\",\n        \"dialog\": {\n          \"title\": \"Nieuwe chat aanmaken\",\n          \"description\": \"Dit zal je huidige chatgeschiedenis wissen. Weet je zeker dat je door wilt gaan?\",\n          \"tooltip\": \"Nieuwe chat\"\n        }\n      },\n      \"user\": {\n        \"menu\": {\n          \"settings\": \"Instellingen\",\n          \"settingsKey\": \"I\",\n          \"apiKeys\": \"API-sleutels\",\n          \"logout\": \"Uitloggen\"\n        }\n      }\n    },\n    \"apiKeys\": {\n      \"title\": \"Vereiste API-sleutels\",\n      \"description\": \"Om deze app te gebruiken zijn de volgende API-sleutels vereist. De sleutels worden opgeslagen in de lokale opslag van je apparaat.\",\n      \"success\": {\n        \"saved\": \"Succesvol opgeslagen\"\n      }\n    },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"Selecteer...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"Kies een datum\",\n        \"range\": \"Kies een datumbereik\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/ta.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"ரத்து செய்\",\n      \"confirm\": \"உறுதிப்படுத்து\",\n      \"continue\": \"தொடர்க\",\n      \"goBack\": \"திரும்பிச் செல்\",\n      \"reset\": \"மீட்டமை\",\n      \"submit\": \"சமர்ப்பி\"\n    },\n    \"status\": {\n      \"loading\": \"ஏற்றுகிறது...\",\n      \"error\": {\n        \"default\": \"பிழை ஏற்பட்டது\",\n        \"serverConnection\": \"சேவையகத்தை அடைய முடியவில்லை\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"பயன்பாட்டை அணுக உள்நுழையவும்\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"மின்னஞ்சல் முகவரி\",\n          \"required\": \"மின்னஞ்சல் தேவையான புலம்\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"கடவுச்சொல்\",\n          \"required\": \"கடவுச்சொல் தேவையான புலம்\"\n        },\n        \"actions\": {\n          \"signin\": \"உள்நுழைக\"\n        },\n        \"alternativeText\": {\n          \"or\": \"அல்லது\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"உள்நுழைய முடியவில்லை\",\n        \"signin\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"oauthSignin\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"redirectUriMismatch\": \"திசைதிருப்பல் URI ஓஆத் பயன்பாட்டு கட்டமைப்புடன் பொருந்தவில்லை\",\n        \"oauthCallback\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"oauthCreateAccount\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"emailCreateAccount\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"callback\": \"வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்\",\n        \"oauthAccountNotLinked\": \"உங்கள் அடையாளத்தை உறுதிப்படுத்த, முதலில் பயன்படுத்திய அதே கணக்குடன் உள்நுழையவும்\",\n        \"emailSignin\": \"மின்னஞ்சலை அனுப்ப முடியவில்லை\",\n        \"emailVerify\": \"உங்கள் மின்னஞ்சலை சரிபார்க்கவும், புதிய மின்னஞ்சல் அனுப்பப்பட்டுள்ளது\",\n        \"credentialsSignin\": \"உள்நுழைவு தோல்வியடைந்தது. நீங்கள் வழங்கிய விவரங்கள் சரியானவை என சரிபார்க்கவும்\",\n        \"sessionRequired\": \"இந்தப் பக்கத்தை அணுக உள்நுழையவும்\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}} மூலம் தொடரவும்\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"உங்கள் செய்தியை இங்கே தட்டச்சு செய்யவும்...\",\n      \"actions\": {\n        \"send\": \"செய்தி அனுப்பு\",\n        \"stop\": \"பணியை நிறுத்து\",\n        \"attachFiles\": \"கோப்புகளை இணை\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"விருப்பமான செய்தியைப் பயன்படுத்தவும்\",\n      \"headline\": \"விருப்பமான செய்திகள்\",\n      \"remove\": \"பிடித்ததை நீக்கு\",\n      \"empty\": {\n        \"title\": \"இன்னும் சேமிக்கப்பட்ட ப்ராம்ப்ட்கள் இல்லை\",\n        \"description\": \"ஒரு ப்ராம்ப்ட் அனுப்பி அதை ஸ்டார் செய்வதன் மூலம் தொடங்கவும் அல்லது முந்தைய அரட்டைகளில் இருந்து ஒரு ப்ராம்ப்ட்டை ஸ்டார் செய்யவும்\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"கருவிகள்\",\n      \"changeTool\": \"கருவியை மாற்றவும்\",\n      \"availableTools\": \"கிடைக்கும் கருவிகள்\"\n    },\n    \"speech\": {\n      \"start\": \"பதிவு தொடங்கு\",\n      \"stop\": \"பதிவை நிறுத்து\",\n      \"connecting\": \"இணைக்கிறது\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"கோப்புகளை இங்கே இழுத்து விடவும்\",\n      \"browse\": \"கோப்புகளை உலாவு\",\n      \"sizeLimit\": \"வரம்பு:\",\n      \"errors\": {\n        \"failed\": \"பதிவேற்றம் தோல்வியடைந்தது\",\n        \"cancelled\": \"பதிவேற்றம் ரத்து செய்யப்பட்டது\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"ரத்து செய்\",\n        \"removeAttachment\": \"இணைப்பை அகற்று\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"பயன்படுத்துகிறது\",\n        \"used\": \"பயன்படுத்தப்பட்டது\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"கிளிப்போர்டுக்கு நகலெடு\",\n          \"success\": \"நகலெடுக்கப்பட்டது!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"பயனுள்ளதாக இருந்தது\",\n        \"negative\": \"பயனுள்ளதாக இல்லை\",\n        \"edit\": \"கருத்தை திருத்து\",\n        \"dialog\": {\n          \"title\": \"கருத்தைச் சேர்\",\n          \"submit\": \"கருத்தை சமர்ப்பி\",\n          \"yourFeedback\": \"உங்கள் கருத்து...\"\n        },\n        \"status\": {\n          \"updating\": \"புதுப்பிக்கிறது\",\n          \"updated\": \"கருத்து புதுப்பிக்கப்பட்டது\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"கடைசி உள்ளீடுகள்\",\n      \"empty\": \"காலியாக உள்ளது...\",\n      \"show\": \"வரலாற்றைக் காட்டு\"\n    },\n    \"settings\": {\n      \"title\": \"அமைப்புகள் பலகம்\",\n      \"customize\": \"உங்கள் உரையாடல் அமைப்புகளை இங்கே தனிப்பயனாக்கவும்\"\n    },\n    \"watermark\": \"LLM கள் தவறுகள் செய்யலாம். முக்கியமான தகவல்களைச் சரிபார்ப்பதைக் கருத்தில் கொள்ளுங்கள்.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"கடந்த உரையாடல்கள்\",\n      \"filters\": {\n        \"search\": \"தேடு\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"இன்று\",\n        \"yesterday\": \"நேற்று\",\n        \"previous7days\": \"கடந்த 7 நாட்கள்\",\n        \"previous30days\": \"கடந்த 30 நாட்கள்\"\n      },\n      \"empty\": \"உரையாடல்கள் எதுவும் இல்லை\",\n      \"actions\": {\n        \"close\": \"பக்கப்பட்டியை மூடு\",\n        \"open\": \"பக்கப்பட்டியை திற\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"தலைப்பிடாத உரையாடல்\",\n      \"menu\": {\n        \"rename\": \"பெயர் மாற்று\",\n        \"share\": \"பகிர்\",\n        \"delete\": \"அழி\"\n      },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"உரையாடல் இணைப்பை பகிரவும்\",\n          \"button\": \"பகிர்\",\n          \"status\": {\n            \"copied\": \"இணைப்பு நகலெடுக்கப்பட்டது\",\n            \"created\": \"பகிர்வு இணைப்பு உருவாக்கப்பட்டது!\",\n            \"unshared\": \"இந்த உரையாடலுக்கு பகிர்வு முடக்கப்பட்டது\"\n          },\n          \"error\": {\n            \"create\": \"பகிர்வு இணைப்பை உருவாக்க முடியவில்லை\",\n            \"unshare\": \"உரையாடல் பகிர்வை நிறுத்த முடியவில்லை\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"நீக்குவதை உறுதிப்படுத்து\",\n          \"description\": \"இது உரையாடல் மற்றும் அதன் செய்திகள், உறுப்புகளை நீக்கும். இந்த செயலை மீட்டெடுக்க முடியாது\",\n          \"success\": \"உரையாடல் நீக்கப்பட்டது\",\n          \"inProgress\": \"உரையாடலை நீக்குகிறது\"\n        },\n        \"rename\": {\n          \"title\": \"உரையாடலை மறுபெயரிடு\",\n          \"description\": \"இந்த உரையாடலுக்கு புதிய பெயரை உள்ளிடவும்\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"பெயர்\",\n              \"placeholder\": \"புதிய பெயரை உள்ளிடவும்\"\n            }\n          },\n          \"success\": \"உரையாடல் மறுபெயரிடப்பட்டது!\",\n          \"inProgress\": \"உரையாடலை மறுபெயரிடுகிறது\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"உரையாடல்\",\n      \"readme\": \"படிக்கவும்\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"புதிய உரையாடல்\",\n      \"dialog\": {\n        \"title\": \"புதிய உரையாடலை உருவாக்கு\",\n        \"description\": \"இது உங்கள் தற்போதைய உரையாடல் வரலாற்றை அழிக்கும். தொடர விரும்புகிறீர்களா?\",\n        \"tooltip\": \"புதிய உரையாடல்\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"அமைப்புகள்\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API விசைகள்\",\n        \"logout\": \"வெளியேறு\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"தேவையான API விசைகள்\",\n    \"description\": \"இந்த பயன்பாட்டைப் பயன்படுத்த, பின்வரும் API விசைகள் தேவை. விசைகள் உங்கள் சாதனத்தின் உள்ளூர் சேமிப்பகத்தில் சேமிக்கப்படும்.\",\n    \"success\": {\n      \"saved\": \"வெற்றிகரமாக சேமிக்கப்பட்டது\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"தேர்ந்தெடுக்கவும்...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"தேதியைத் தேர்ந்தெடுக்கவும்\",\n        \"range\": \"தேதி வரம்பைத் தேர்ந்தெடுக்கவும்\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/te.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"రద్దు చేయండి\",\n      \"confirm\": \"నిర్ధారించండి\",\n      \"continue\": \"కొనసాగించండి\",\n      \"goBack\": \"వెనక్కి వెళ్ళండి\",\n      \"reset\": \"రీసెట్ చేయండి\",\n      \"submit\": \"సమర్పించండి\"\n    },\n    \"status\": {\n      \"loading\": \"లోడ్ అవుతోంది...\",\n      \"error\": {\n        \"default\": \"లోపం సంభవించింది\",\n        \"serverConnection\": \"సర్వర్‌ని చేరుకోలేకపోయాము\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"యాప్‌ని ఉపయోగించడానికి లాగిన్ చేయండి\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"ఇమెయిల్ చిరునామా\",\n          \"required\": \"ఇమెయిల్ తప్పనిసరి\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"పాస్‌వర్డ్\",\n          \"required\": \"పాస్‌వర్డ్ తప్పనిసరి\"\n        },\n        \"actions\": {\n          \"signin\": \"సైన్ ఇన్ చేయండి\"\n        },\n        \"alternativeText\": {\n          \"or\": \"లేదా\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"సైన్ ఇన్ చేయలేకపోయాము\",\n        \"signin\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"oauthSignin\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"redirectUriMismatch\": \"రీడైరెక్ట్ URI oauth యాప్ కాన్ఫిగరేషన్‌తో సరిపోలడం లేదు\",\n        \"oauthCallback\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"oauthCreateAccount\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"emailCreateAccount\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"callback\": \"వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి\",\n        \"oauthAccountNotLinked\": \"మీ గుర్తింపును నిర్ధారించడానికి, మీరు మొదట ఉపయోగించిన అదే ఖాతాతో సైన్ ఇన్ చేయండి\",\n        \"emailSignin\": \"ఇమెయిల్ పంపడం సాధ్యం కాలేదు\",\n        \"emailVerify\": \"దయచేసి మీ ఇమెయిల్‌ని ధృవీకరించండి, కొత్త ఇమెయిల్ పంపబడింది\",\n        \"credentialsSignin\": \"సైన్ ఇన్ విఫలమైంది. మీరు అందించిన వివరాలు సరైనవేనా అని తనిఖీ చేయండి\",\n        \"sessionRequired\": \"ఈ పేజీని యాక్సెస్ చేయడానికి దయచేసి సైన్ ఇన్ చేయండి\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"{{provider}}తో కొనసాగించండి\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"మీ సందేశాన్ని ఇక్కడ టైప్ చేయండి...\",\n      \"actions\": {\n        \"send\": \"సందేశం పంపండి\",\n        \"stop\": \"పని ఆపండి\",\n        \"attachFiles\": \"ఫైల్స్ జోడించండి\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"రికార్డింగ్ ప్రారంభించండి\",\n      \"stop\": \"రికార్డింగ్ ఆపండి\",\n      \"connecting\": \"అనుసంధానిస్తోంది\"\n    },\n    \"favorites\": {\n      \"use\": \"ఇష్టమైన సందేశాన్ని ఉపయోగించండి\",\n      \"headline\": \"ఇష్టమైన సందేశాలు\",\n      \"remove\": \"ఇష్టమైనదాన్ని తొలగించండి\",\n      \"empty\": {\n        \"title\": \"ఇంకా ప్రాంప్ట్‌లు సేవ్ చేయలేదు\",\n        \"description\": \"ఒక ప్రాంప్ట్ పంపి దానికి స్టార్ చేయడం ద్వారా ప్రారంభించండి లేదా మునుపటి చాట్‌ల నుండి ప్రాంప్ట్‌కు స్టార్ చేయండి\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"పరికరాలు\",\n      \"changeTool\": \"పరికరాన్ని మార్చండి\",\n      \"availableTools\": \"లభ్యమైన పరికరాలు\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"ఫైల్స్‌ని ఇక్కడ డ్రాగ్ చేసి డ్రాప్ చేయండి\",\n      \"browse\": \"ఫైల్స్ బ్రౌజ్ చేయండి\",\n      \"sizeLimit\": \"పరిమితి:\",\n      \"errors\": {\n        \"failed\": \"అప్‌లోడ్ విఫలమైంది\",\n        \"cancelled\": \"అప్‌లోడ్ రద్దు చేయబడింది\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"రద్దు చేయండి\",\n        \"removeAttachment\": \"అనుబంధాన్ని తొలగించండి\"\n      }\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"ఉపయోగిస్తోంది\",\n        \"used\": \"ఉపయోగించబడింది\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"క్లిప్‌బోర్డ్‌కి కాపీ చేయండి\",\n          \"success\": \"కాపీ చేయబడింది!\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"సహాయకరం\",\n        \"negative\": \"సహాయకరం కాదు\",\n        \"edit\": \"అభిప్రాయాన్ని సవరించండి\",\n        \"dialog\": {\n          \"title\": \"వ్యాఖ్య జోడించండి\",\n          \"submit\": \"అభిప్రాయాన్ని సమర్పించండి\",\n          \"yourFeedback\": \"మీ అభిప్రాయం...\"\n        },\n        \"status\": {\n          \"updating\": \"నవీకరిస్తోంది\",\n          \"updated\": \"అభిప్రాయం నవీకరించబడింది\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"చివరి ఇన్‌పుట్‌లు\",\n      \"empty\": \"ఖాళీగా ఉంది...\",\n      \"show\": \"చరిత్రను చూపించు\"\n    },\n    \"settings\": {\n      \"title\": \"సెట్టింగ్‌ల ప్యానెల్\",\n      \"customize\": \"మీ చాట్ సెట్టింగ్‌లను ఇక్కడ అనుకూలీకరించండి\"\n    },\n    \"watermark\": \"LLMలు తప్పులు చేయవచ్చు. ముఖ్యమైన సమాచారాన్ని తనిఖీ చేయడాన్ని పరిగణించండి.\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"గత చాట్‌లు\",\n      \"filters\": {\n        \"search\": \"వెతకండి\",\n        \"placeholder\": \"Search conversations...\"\n      },\n      \"timeframes\": {\n        \"today\": \"ఈరోజు\",\n        \"yesterday\": \"నిన్న\",\n        \"previous7days\": \"గత 7 రోజులు\",\n        \"previous30days\": \"గత 30 రోజులు\"\n      },\n      \"empty\": \"థ్రెడ్‌లు కనుగొనబడలేదు\",\n      \"actions\": {\n        \"close\": \"సైడ్‌బార్ మూసివేయండి\",\n        \"open\": \"సైడ్‌బార్ తెరవండి\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"పేరు లేని సంభాషణ\",\n      \"menu\": {\n          \"rename\": \"పేరు మార్చండి\",\n          \"share\": \"షేర్ చేయండి\",\n          \"delete\": \"తొలగించండి\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"చాట్ లింక్‌ను షేర్ చేయండి\",\n          \"button\": \"షేర్ చేయండి\",\n          \"status\": {\n            \"copied\": \"లింక్ కాపీ చేయబడింది\",\n            \"created\": \"షేర్ లింక్ సృష్టించబడింది!\",\n            \"unshared\": \"ఈ థ్రెడ్‌కు షేరింగ్ ఆపివేయబడింది\"\n          },\n          \"error\": {\n            \"create\": \"షేర్ లింక్ సృష్టించడం విఫలమైంది\",\n            \"unshare\": \"థ్రెడ్ షేరింగ్ నిలిపివేయడం విఫలమైంది\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"తొలగింపును నిర్ధారించండి\",\n          \"description\": \"ఇది థ్రెడ్‌తో పాటు దాని సందేశాలను మరియు అంశాలను తొలగిస్తుంది. ఈ చర్యను రద్దు చేయలేరు\",\n          \"success\": \"చాట్ తొలగించబడింది\",\n          \"inProgress\": \"చాట్‌ని తొలగిస్తోంది\"\n        },\n        \"rename\": {\n          \"title\": \"థ్రెడ్ పేరు మార్చండి\",\n          \"description\": \"ఈ థ్రెడ్ కోసం కొత్త పేరును నమోదు చేయండి\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"పేరు\",\n              \"placeholder\": \"కొత్త పేరును నమోదు చేయండి\"\n            }\n          },\n          \"success\": \"థ్రెడ్ పేరు మార్చబడింది!\",\n          \"inProgress\": \"థ్రెడ్ పేరు మారుస్తోంది\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"చాట్\",\n      \"readme\": \"చదవండి\",\n      \"theme\": {\n        \"light\": \"Light Theme\",\n        \"dark\": \"Dark Theme\",\n        \"system\": \"Follow System\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"కొత్త చాట్\",\n      \"dialog\": {\n        \"title\": \"కొత్త చాట్ సృష్టించండి\",\n        \"description\": \"ఇది మీ ప్రస్తుత చాట్ చరిత్రను తుడిచివేస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?\",\n        \"tooltip\": \"కొత్త చాట్\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"సెట్టింగ్‌లు\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API కీలు\",\n        \"logout\": \"లాగ్ అవుట్\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"అవసరమైన API కీలు\",\n    \"description\": \"ఈ యాప్‌ని ఉపయోగించడానికి, కింది API కీలు అవసరం. కీలు మీ పరికరం యొక్క స్థానిక నిల్వలో నిల్వ చేయబడతాయి.\",\n    \"success\": {\n      \"saved\": \"విజయవంతంగా సేవ్ చేయబడింది\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"Info\",\n    \"note\": \"Note\",\n    \"tip\": \"Tip\",\n    \"important\": \"Important\",\n    \"warning\": \"Warning\",\n    \"caution\": \"Caution\",\n    \"debug\": \"Debug\",\n    \"example\": \"Example\",\n    \"success\": \"Success\",\n    \"help\": \"Help\",\n    \"idea\": \"Idea\",\n    \"pending\": \"Pending\",\n    \"security\": \"Security\",\n    \"beta\": \"Beta\",\n    \"best-practice\": \"Best Practice\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"ఎంచుకోండి...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"తేదీని ఎంచుకోండి\",\n        \"range\": \"తేదీ పరిధిని ఎంచుకోండి\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations/zh-CN.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"取消\",\n      \"confirm\": \"确认\",\n      \"continue\": \"继续\",\n      \"goBack\": \"返回\",\n      \"reset\": \"重置\",\n      \"submit\": \"提交\"\n    },\n    \"status\": {\n      \"loading\": \"加载中...\",\n      \"error\": {\n        \"default\": \"发生错误\",\n        \"serverConnection\": \"无法连接到服务器\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"登录以访问应用\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"电子邮箱\",\n          \"required\": \"邮箱是必填项\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"密码\",\n          \"required\": \"密码是必填项\"\n        },\n        \"actions\": {\n          \"signin\": \"登录\"\n        },\n        \"alternativeText\": {\n          \"or\": \"或\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"无法登录\",\n        \"signin\": \"请尝试使用其他账号登录\",\n        \"oauthSignin\": \"请尝试使用其他账号登录\",\n        \"redirectUriMismatch\": \"重定向URI与OAuth应用配置不匹配\",\n        \"oauthCallback\": \"请尝试使用其他账号登录\",\n        \"oauthCreateAccount\": \"请尝试使用其他账号登录\",\n        \"emailCreateAccount\": \"请尝试使用其他账号登录\",\n        \"callback\": \"请尝试使用其他账号登录\",\n        \"oauthAccountNotLinked\": \"为确认您的身份，请使用原始账号登录\",\n        \"emailSignin\": \"邮件发送失败\",\n        \"emailVerify\": \"请验证您的邮箱，新的验证邮件已发送\",\n        \"credentialsSignin\": \"登录失败。请检查您提供的信息是否正确\",\n        \"sessionRequired\": \"请登录以访问此页面\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"继续使用{{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"在此输入您的消息...\",\n      \"actions\": {\n        \"send\": \"发送消息\",\n        \"stop\": \"停止任务\",\n        \"attachFiles\": \"附加文件\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"开始录音\",\n      \"stop\": \"停止录音\",\n      \"connecting\": \"连接中\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"将文件拖放到这里\",\n      \"browse\": \"浏览文件\",\n      \"sizeLimit\": \"限制：\",\n      \"errors\": {\n        \"failed\": \"上传失败\",\n        \"cancelled\": \"已取消上传\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"取消上传\",\n        \"removeAttachment\": \"移除附件\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"使用收藏的消息\",\n      \"headline\": \"收藏的消息\",\n      \"remove\": \"移除收藏\",\n      \"empty\": {\n        \"title\": \"尚未保存的提示\",\n        \"description\": \"从发送提示并加星标开始，或从之前的聊天中加星标提示\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"工具\",\n      \"changeTool\": \"更换工具\",\n      \"availableTools\": \"可用工具\"\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"使用中\",\n        \"used\": \"已使用\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"复制到剪贴板\",\n          \"success\": \"已复制！\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"有帮助\",\n        \"negative\": \"没有帮助\",\n        \"edit\": \"编辑反馈\",\n        \"dialog\": {\n          \"title\": \"添加评论\",\n          \"submit\": \"提交反馈\",\n          \"yourFeedback\": \"您的反馈...\"\n        },\n        \"status\": {\n          \"updating\": \"更新中\",\n          \"updated\": \"反馈已更新\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"最近输入\",\n      \"empty\": \"空空如也...\",\n      \"show\": \"显示历史\"\n    },\n    \"settings\": {\n      \"title\": \"设置面板\",\n      \"customize\": \"在此自定义您的聊天设置\"\n    },\n    \"watermark\": \"大语言模型可能会犯错。请核实重要信息。\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"历史对话\",\n      \"filters\": {\n        \"search\": \"搜索\",\n        \"placeholder\": \"搜索会话...\"\n      },\n      \"timeframes\": {\n        \"today\": \"今天\",\n        \"yesterday\": \"昨天\",\n        \"previous7days\": \"过去7天\",\n        \"previous30days\": \"过去30天\"\n      },\n      \"empty\": \"未找到对话\",\n      \"actions\": {\n        \"close\": \"关闭侧边栏\",\n        \"open\": \"打开侧边栏\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"未命名对话\",\n      \"menu\": {\n          \"rename\": \"重命名\",\n          \"share\": \"分享\",\n          \"delete\": \"删除\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"分享聊天链接\",\n          \"button\": \"分享\",\n          \"status\": {\n            \"copied\": \"链接已复制\",\n            \"created\": \"分享链接已创建！\",\n            \"unshared\": \"已禁用此对话的分享\"\n          },\n          \"error\": {\n            \"create\": \"创建分享链接失败\",\n            \"unshare\": \"取消对话分享失败\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"确认删除\",\n          \"description\": \"这将删除该对话及其所有消息和元素。此操作无法撤销\",\n          \"success\": \"对话已删除\",\n          \"inProgress\": \"正在删除对话\"\n        },\n        \"rename\": {\n          \"title\": \"重命名对话\",\n          \"description\": \"为此对话输入新名称\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"名称\",\n              \"placeholder\": \"输入新名称\"\n            }\n          },\n          \"success\": \"对话已重命名！\",\n          \"inProgress\": \"正在重命名对话\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"聊天\",\n      \"readme\": \"说明\",\n      \"theme\": {\n        \"light\": \"浅色主题\",\n        \"dark\": \"深色主题\",\n        \"system\": \"跟随系统\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"新建对话\",\n      \"dialog\": {\n        \"title\": \"创建新对话\",\n        \"description\": \"这将清除您当前的聊天记录。确定要继续吗？\",\n        \"tooltip\": \"新建对话\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"设置\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API密钥\",\n        \"logout\": \"退出登录\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"所需API密钥\",\n    \"description\": \"使用此应用需要以下API密钥。这些密钥存储在您设备的本地存储中。\",\n    \"success\": {\n      \"saved\": \"保存成功\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"信息\",\n    \"note\": \"注释\",\n    \"tip\": \"提示\",\n    \"important\": \"重要\",\n    \"warning\": \"警告\",\n    \"caution\": \"注意\",\n    \"debug\": \"调试\",\n    \"example\": \"示例\",\n    \"success\": \"成功\",\n    \"help\": \"帮助\",\n    \"idea\": \"想法\",\n    \"pending\": \"待处理\",\n    \"security\": \"安全\",\n    \"beta\": \"测试\",\n    \"best-practice\": \"最佳实践\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"选择...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"选择日期\",\n        \"range\": \"选择日期范围\"\n      }\n    }\n  }\n}\n\n"
  },
  {
    "path": "backend/chainlit/translations/zh-TW.json",
    "content": "{\n  \"common\": {\n    \"actions\": {\n      \"cancel\": \"取消\",\n      \"confirm\": \"確認\",\n      \"continue\": \"繼續\",\n      \"goBack\": \"返回\",\n      \"reset\": \"重設\",\n      \"submit\": \"送出\"\n    },\n    \"status\": {\n      \"loading\": \"載入中...\",\n      \"error\": {\n        \"default\": \"發生錯誤\",\n        \"serverConnection\": \"無法連線到伺服器\"\n      }\n    }\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"登入以存取應用程式\",\n      \"form\": {\n        \"email\": {\n          \"label\": \"電子信箱\",\n          \"required\": \"信箱是必填項目\",\n          \"placeholder\": \"me@example.com\"\n        },\n        \"password\": {\n          \"label\": \"密碼\",\n          \"required\": \"密碼是必填項目\"\n        },\n        \"actions\": {\n          \"signin\": \"登入\"\n        },\n        \"alternativeText\": {\n          \"or\": \"或\"\n        }\n      },\n      \"errors\": {\n        \"default\": \"無法登入\",\n        \"signin\": \"請嘗試使用其它帳號登入\",\n        \"oauthSignin\": \"請嘗試使用其它帳號登入\",\n        \"redirectUriMismatch\": \"重新導向URI與OAuth App設定不相符\",\n        \"oauthCallback\": \"請嘗試使用其它帳號登入\",\n        \"oauthCreateAccount\": \"請嘗試使用其它帳號登入\",\n        \"emailCreateAccount\": \"請嘗試使用其它帳號登入\",\n        \"callback\": \"請嘗試使用其它帳號登入\",\n        \"oauthAccountNotLinked\": \"為確認您的身份，請以原本使用的帳號登入\",\n        \"emailSignin\": \"電子郵件發送失敗\",\n        \"emailVerify\": \"請驗證您的電子信箱，新的驗證郵件已發送\",\n        \"credentialsSignin\": \"登入失敗。請檢查您提供的資訊是否正確\",\n        \"sessionRequired\": \"請登入以存取此頁面\"\n      }\n    },\n    \"provider\": {\n      \"continue\": \"繼續使用{{provider}}\"\n    }\n  },\n  \"chat\": {\n    \"input\": {\n      \"placeholder\": \"在此輸入您的訊息...\",\n      \"actions\": {\n        \"send\": \"發送訊息\",\n        \"stop\": \"停止任務\",\n        \"attachFiles\": \"附加檔案\"\n      }\n    },\n    \"speech\": {\n      \"start\": \"開始錄音\",\n      \"stop\": \"停止錄音\",\n      \"connecting\": \"連線中\"\n    },\n    \"fileUpload\": {\n      \"dragDrop\": \"拖曳檔案到這裡\",\n      \"browse\": \"瀏覽檔案\",\n      \"sizeLimit\": \"限制：\",\n      \"errors\": {\n        \"failed\": \"上傳失敗\",\n        \"cancelled\": \"已取消上傳\"\n      },\n      \"actions\": {\n        \"cancelUpload\": \"取消上傳\",\n        \"removeAttachment\": \"移除附件\"\n      }\n    },\n    \"favorites\": {\n      \"use\": \"使用收藏的訊息\",\n      \"headline\": \"收藏的訊息\",\n      \"remove\": \"移除收藏\",\n      \"empty\": {\n        \"title\": \"尚未儲存的提示\",\n        \"description\": \"從發送提示並加星號開始，或從之前的聊天中加星號提示\"\n      }\n    },\n    \"commands\": {\n      \"button\": \"工具\",\n      \"changeTool\": \"更換工具\",\n      \"availableTools\": \"可用工具\"\n    },\n    \"messages\": {\n      \"status\": {\n        \"using\": \"正在使用\",\n        \"used\": \"已使用\"\n      },\n      \"actions\": {\n        \"copy\": {\n          \"button\": \"複製到剪貼簿\",\n          \"success\": \"已複製！\"\n        }\n      },\n      \"feedback\": {\n        \"positive\": \"有幫助\",\n        \"negative\": \"沒有幫助\",\n        \"edit\": \"編輯回饋\",\n        \"dialog\": {\n          \"title\": \"新增評論\",\n          \"submit\": \"送出回饋\",\n          \"yourFeedback\": \"您的回饋...\"\n        },\n        \"status\": {\n          \"updating\": \"更新中\",\n          \"updated\": \"回饋已更新\"\n        }\n      }\n    },\n    \"history\": {\n      \"title\": \"最近輸入\",\n      \"empty\": \"空空如也...\",\n      \"show\": \"顯示歷史\"\n    },\n    \"settings\": {\n      \"title\": \"設定面板\",\n      \"customize\": \"在此自定義您的聊天設定\"\n    },\n    \"watermark\": \"大型語言模型可能會犯錯。請核實重要資訊。\"\n  },\n  \"threadHistory\": {\n    \"sidebar\": {\n      \"title\": \"歷史對話\",\n      \"filters\": {\n        \"search\": \"搜尋\",\n        \"placeholder\": \"搜尋對話...\"\n      },\n      \"timeframes\": {\n        \"today\": \"今天\",\n        \"yesterday\": \"昨天\",\n        \"previous7days\": \"過去7天\",\n        \"previous30days\": \"過去30天\"\n      },\n      \"empty\": \"未找到對話\",\n      \"actions\": {\n        \"close\": \"關閉側邊欄\",\n        \"open\": \"打開側邊欄\"\n      }\n    },\n    \"thread\": {\n      \"untitled\": \"未命名對話\",\n      \"menu\": {\n          \"rename\": \"重新命名\",\n          \"share\": \"分享\",\n          \"delete\": \"刪除\"\n        },\n      \"actions\": {\n        \"share\": {\n          \"title\": \"分享聊天連結\",\n          \"button\": \"分享\",\n          \"status\": {\n            \"copied\": \"連結已複製\",\n            \"created\": \"分享連結已建立！\",\n            \"unshared\": \"已停用此對話的分享\"\n          },\n          \"error\": {\n            \"create\": \"建立分享連結失敗\",\n            \"unshare\": \"取消對話分享失敗\"\n          }\n        },\n        \"delete\": {\n          \"title\": \"確認刪除\",\n          \"description\": \"這將刪除該對話及其所有訊息和元件。此操作無法復原。\",\n          \"success\": \"對話已刪除\",\n          \"inProgress\": \"正在刪除對話\"\n        },\n        \"rename\": {\n          \"title\": \"重新命名對話\",\n          \"description\": \"為此對話輸入新名稱\",\n          \"form\": {\n            \"name\": {\n              \"label\": \"名稱\",\n              \"placeholder\": \"輸入新名稱\"\n            }\n          },\n          \"success\": \"對話已重新命名！\",\n          \"inProgress\": \"正在重新命名對話\"\n        }\n      }\n    }\n  },\n  \"navigation\": {\n    \"header\": {\n      \"chat\": \"聊天\",\n      \"readme\": \"說明\",\n      \"theme\": {\n        \"light\": \"淺色主題\",\n        \"dark\": \"深色主題\",\n        \"system\": \"跟隨系統\"\n      }\n    },\n    \"newChat\": {\n      \"button\": \"新建對話\",\n      \"dialog\": {\n        \"title\": \"創建新對話\",\n        \"description\": \"這將清除您當前的聊天記錄。確定要繼續嗎？\",\n        \"tooltip\": \"新建對話\"\n      }\n    },\n    \"user\": {\n      \"menu\": {\n        \"settings\": \"設定\",\n        \"settingsKey\": \"S\",\n        \"apiKeys\": \"API金鑰\",\n        \"logout\": \"登出\"\n      }\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"所需API金鑰\",\n    \"description\": \"使用此應用程式需要以下API金鑰。這些金鑰儲存在您設備的本地儲存空間中。\",\n    \"success\": {\n      \"saved\": \"儲存成功\"\n    }\n  },\n  \"alerts\": {\n    \"info\": \"資訊\",\n    \"note\": \"注釋\",\n    \"tip\": \"提示\",\n    \"important\": \"重要\",\n    \"warning\": \"警告\",\n    \"caution\": \"注意\",\n    \"debug\": \"除錯\",\n    \"example\": \"範例\",\n    \"success\": \"成功\",\n    \"help\": \"幫助\",\n    \"idea\": \"想法\",\n    \"pending\": \"待處理\",\n    \"security\": \"安全\",\n    \"beta\": \"測試\",\n    \"best-practice\": \"最佳實踐\"\n  },\n  \"components\": {\n    \"MultiSelectInput\": {\n      \"placeholder\": \"選擇...\"\n    },\n    \"DatePickerInput\": {\n      \"placeholder\": {\n        \"single\": \"選擇日期\",\n        \"range\": \"選擇日期範圍\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "backend/chainlit/translations.py",
    "content": "# TODO:\n# - Support linting plural\n# - Support interpolation\n\n\ndef compare_json_structures(truth, to_compare, path=\"\"):\n    \"\"\"\n    Compare the structure of two deeply nested JSON objects.\n    Args:\n        truth (dict): The 'truth' JSON object.\n        to_compare (dict): The 'to_compare' JSON object.\n        path (str): The current path for error reporting (used internally).\n    Returns:\n        A list of differences found.\n    \"\"\"\n    if not isinstance(truth, dict) or not isinstance(to_compare, dict):\n        raise ValueError(\"Both inputs must be dictionaries.\")\n\n    errors = []\n\n    truth_keys = set(truth.keys())\n    to_compare_keys = set(to_compare.keys())\n\n    extra_keys = to_compare_keys - truth_keys\n    missing_keys = truth_keys - to_compare_keys\n\n    for key in extra_keys:\n        errors.append(f\"⚠️ Extra key: '{path + '.' + key if path else key}'\")\n\n    for key in missing_keys:\n        errors.append(f\"❌ Missing key: '{path + '.' + key if path else key}'\")\n\n    for key in truth_keys & to_compare_keys:\n        if isinstance(truth[key], dict) and isinstance(to_compare[key], dict):\n            # Recursive call to navigate through nested dictionaries\n            errors += compare_json_structures(\n                truth[key], to_compare[key], path + \".\" + key if path else key\n            )\n        elif not isinstance(truth[key], dict) and not isinstance(to_compare[key], dict):\n            # If both are not dicts, we are at leaf nodes and structure matches; skip value comparison\n            continue\n        else:\n            # Structure mismatch: one is a dict, the other is not\n            errors.append(\n                f\"❌ Structure mismatch at: '{path + '.' + key if path else key}'\"\n            )\n\n    return errors\n\n\ndef lint_translation_json(file, truth, to_compare):\n    print(f\"\\nLinting {file}...\")\n\n    errors = compare_json_structures(truth, to_compare)\n\n    if errors:\n        for error in errors:\n            print(f\"{error}\")\n    else:\n        print(f\"✅ No errors found in {file}\")\n"
  },
  {
    "path": "backend/chainlit/types.py",
    "content": "from enum import Enum\nfrom pathlib import Path\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Dict,\n    Generic,\n    List,\n    Literal,\n    Optional,\n    Protocol,\n    TypedDict,\n    TypeVar,\n    Union,\n)\n\nif TYPE_CHECKING:\n    from chainlit.element import ElementDict\n    from chainlit.step import StepDict\n\nfrom dataclasses import field\n\nfrom dataclasses_json import DataClassJsonMixin\nfrom pydantic import BaseModel\nfrom pydantic.dataclasses import dataclass\n\nInputWidgetType = Literal[\n    \"switch\",\n    \"slider\",\n    \"select\",\n    \"textinput\",\n    \"tags\",\n    \"numberinput\",\n    \"multiselect\",\n    \"checkbox\",\n    \"radio\",\n    \"datepicker\",\n]\nToastType = Literal[\"info\", \"success\", \"warning\", \"error\"]\n\n\nclass ThreadDict(TypedDict):\n    id: str\n    createdAt: str\n    name: Optional[str]\n    userId: Optional[str]\n    userIdentifier: Optional[str]\n    tags: Optional[List[str]]\n    metadata: Optional[Dict]\n    steps: List[\"StepDict\"]\n    elements: Optional[List[\"ElementDict\"]]\n\n\nclass Pagination(BaseModel):\n    first: int\n    cursor: Optional[str] = None\n\n\nclass ThreadFilter(BaseModel):\n    feedback: Literal[0, 1] | None = None\n    userId: str | None = None\n    search: str | None = None\n\n\n@dataclass\nclass PageInfo:\n    hasNextPage: bool\n    startCursor: Optional[str]\n    endCursor: Optional[str]\n\n    def to_dict(self):\n        return {\n            \"hasNextPage\": self.hasNextPage,\n            \"startCursor\": self.startCursor,\n            \"endCursor\": self.endCursor,\n        }\n\n    @classmethod\n    def from_dict(cls, page_info_dict: Dict) -> \"PageInfo\":\n        hasNextPage = page_info_dict.get(\"hasNextPage\", False)\n        startCursor = page_info_dict.get(\"startCursor\", None)\n        endCursor = page_info_dict.get(\"endCursor\", None)\n        return cls(\n            hasNextPage=hasNextPage, startCursor=startCursor, endCursor=endCursor\n        )\n\n\nT = TypeVar(\"T\", covariant=True)\n\n\nclass HasFromDict(Protocol[T]):\n    @classmethod\n    def from_dict(cls, obj_dict: Any) -> T:\n        raise NotImplementedError\n\n\n@dataclass\nclass PaginatedResponse(Generic[T]):\n    pageInfo: PageInfo\n    data: List[T]\n\n    def to_dict(self):\n        return {\n            \"pageInfo\": self.pageInfo.to_dict(),\n            \"data\": [\n                (d.to_dict() if hasattr(d, \"to_dict\") and callable(d.to_dict) else d)\n                for d in self.data\n            ],\n        }\n\n    @classmethod\n    def from_dict(\n        cls, paginated_response_dict: Dict, the_class: HasFromDict[T]\n    ) -> \"PaginatedResponse[T]\":\n        pageInfo = PageInfo.from_dict(paginated_response_dict.get(\"pageInfo\", {}))\n\n        data = [the_class.from_dict(d) for d in paginated_response_dict.get(\"data\", [])]\n\n        return cls(pageInfo=pageInfo, data=data)\n\n\n@dataclass\nclass FileSpec(DataClassJsonMixin):\n    accept: Union[List[str], Dict[str, List[str]]]\n    max_files: int\n    max_size_mb: int\n\n\n@dataclass\nclass ActionSpec(DataClassJsonMixin):\n    keys: List[str]\n\n\n@dataclass\nclass AskSpec(DataClassJsonMixin):\n    \"\"\"Specification for asking the user.\"\"\"\n\n    timeout: int\n    type: Literal[\"text\", \"file\", \"action\", \"element\"]\n    step_id: str\n\n\n@dataclass\nclass AskFileSpec(FileSpec, AskSpec, DataClassJsonMixin):\n    \"\"\"Specification for asking the user a file.\"\"\"\n\n\n@dataclass\nclass AskActionSpec(ActionSpec, AskSpec, DataClassJsonMixin):\n    \"\"\"Specification for asking the user an action\"\"\"\n\n\n@dataclass\nclass AskElementSpec(AskSpec, DataClassJsonMixin):\n    \"\"\"Specification for asking the user a custom element\"\"\"\n\n    element_id: str\n\n\nclass FileReference(TypedDict):\n    id: str\n\n\nclass FileDict(TypedDict):\n    id: str\n    name: str\n    path: Path\n    size: int\n    type: str\n\n\nclass MessagePayload(TypedDict):\n    message: \"StepDict\"\n    fileReferences: Optional[List[FileReference]]\n\n\nclass InputAudioChunkPayload(TypedDict):\n    isStart: bool\n    mimeType: str\n    elapsedTime: float\n    data: bytes\n\n\n@dataclass\nclass InputAudioChunk:\n    isStart: bool\n    mimeType: str\n    elapsedTime: float\n    data: bytes\n\n\nclass OutputAudioChunk(TypedDict):\n    track: str\n    mimeType: str\n    data: bytes\n\n\n@dataclass\nclass AskFileResponse:\n    id: str\n    name: str\n    path: str\n    size: int\n    type: str\n\n\nclass AskActionResponse(TypedDict):\n    name: str\n    payload: Dict\n    label: str\n    tooltip: str\n    forId: str\n    id: str\n\n\nclass AskElementResponse(TypedDict, total=False):\n    submitted: bool\n\n\nclass UpdateThreadRequest(BaseModel):\n    threadId: str\n    name: str\n\n\nclass ShareThreadRequest(BaseModel):\n    threadId: str\n    isShared: bool\n\n\nclass DeleteThreadRequest(BaseModel):\n    threadId: str\n\n\nclass DeleteFeedbackRequest(BaseModel):\n    feedbackId: str\n\n\nclass GetThreadsRequest(BaseModel):\n    pagination: Pagination\n    filter: ThreadFilter\n\n\nclass CallActionRequest(BaseModel):\n    action: Dict\n    sessionId: str\n\n\nclass ConnectStdioMCPRequest(BaseModel):\n    sessionId: str\n    clientType: Literal[\"stdio\"]\n    name: str\n    fullCommand: str\n\n\nclass ConnectSseMCPRequest(BaseModel):\n    sessionId: str\n    clientType: Literal[\"sse\"]\n    name: str\n    url: str\n    # Optional HTTP headers to forward to the MCP transport (e.g. Authorization)\n    headers: Optional[Dict[str, str]] = None\n\n\nclass ConnectStreamableHttpMCPRequest(BaseModel):\n    sessionId: str\n    clientType: Literal[\"streamable-http\"]\n    name: str\n    url: str\n    # Optional HTTP headers to forward to the MCP transport (e.g. Authorization)\n    headers: Dict[str, str] | None = None\n\n\nConnectMCPRequest = Union[\n    ConnectStdioMCPRequest, ConnectSseMCPRequest, ConnectStreamableHttpMCPRequest\n]\n\n\nclass DisconnectMCPRequest(BaseModel):\n    sessionId: str\n    name: str\n\n\nclass ElementRequest(BaseModel):\n    element: Dict\n    sessionId: str\n\n\nclass Theme(str, Enum):\n    light = \"light\"\n    dark = \"dark\"\n\n\n@dataclass\nclass Starter(DataClassJsonMixin):\n    \"\"\"Specification for a starter that can be chosen by the user at the thread start.\"\"\"\n\n    label: str\n    message: str\n    command: Optional[str] = None\n    icon: Optional[str] = None\n\n\n@dataclass\nclass StarterCategory(DataClassJsonMixin):\n    \"\"\"A category/group of starters with an optional icon.\"\"\"\n\n    label: str\n    icon: Optional[str] = None\n    starters: List[Starter] = field(default_factory=list)\n\n\n@dataclass\nclass ChatProfile(DataClassJsonMixin):\n    \"\"\"Specification for a chat profile that can be chosen by the user at the thread start.\"\"\"\n\n    name: str\n    markdown_description: str\n    icon: Optional[str] = None\n    display_name: Optional[str] = None\n    default: bool = False\n    starters: Optional[List[Starter]] = None\n    config_overrides: Any = None\n\n\nFeedbackStrategy = Literal[\"BINARY\"]\n\n\nclass CommandDict(TypedDict):\n    # The identifier of the command, will be displayed in the UI\n    id: str\n    # The description of the command, will be displayed in the UI\n    description: str\n    # The lucide icon name\n    icon: str\n    # Display the command as a button in the composer\n    button: Optional[bool]\n    # Whether the command will be persistent unless the user toggles it\n    persistent: Optional[bool]\n    # Whether the command should be pre-selected when loaded\n    selected: Optional[bool]\n\n\nclass FeedbackDict(TypedDict):\n    forId: str\n    id: Optional[str]\n    value: Literal[0, 1]\n    comment: Optional[str]\n\n\n@dataclass\nclass Feedback:\n    forId: str\n    value: Literal[0, 1]\n    threadId: Optional[str] = None\n    id: Optional[str] = None\n    comment: Optional[str] = None\n\n\nclass UpdateFeedbackRequest(BaseModel):\n    feedback: Feedback\n    sessionId: str\n"
  },
  {
    "path": "backend/chainlit/user.py",
    "content": "from typing import Dict, Literal, Optional, TypedDict\n\nfrom dataclasses_json import DataClassJsonMixin\nfrom pydantic import Field\nfrom pydantic.dataclasses import dataclass\n\nProvider = Literal[\n    \"credentials\",\n    \"header\",\n    \"github\",\n    \"google\",\n    \"azure-ad\",\n    \"azure-ad-hybrid\",\n    \"okta\",\n    \"auth0\",\n    \"descope\",\n]\n\n\nclass UserDict(TypedDict):\n    id: str\n    identifier: str\n    display_name: Optional[str]\n    metadata: Dict\n\n\n# Used when logging-in a user\n@dataclass\nclass User(DataClassJsonMixin):\n    identifier: str\n    display_name: Optional[str] = None\n    metadata: Dict = Field(default_factory=dict)\n\n\n@dataclass\nclass PersistedUserFields:\n    id: str\n    createdAt: str\n\n\n@dataclass\nclass PersistedUser(User, PersistedUserFields):\n    pass\n"
  },
  {
    "path": "backend/chainlit/user_session.py",
    "content": "from typing import Callable, Dict, Generic, Optional, TypeVar\n\nfrom chainlit.context import context\n\nuser_sessions: Dict[str, Dict] = {}\n\nT = TypeVar(\"T\")\n\n\nclass UserSession:\n    \"\"\"\n    Developer facing user session class.\n    Useful for the developer to store user specific data between calls.\n    \"\"\"\n\n    def get(self, key, default=None):\n        if not context.session:\n            return default\n\n        if context.session.id not in user_sessions:\n            # Create a new user session\n            user_sessions[context.session.id] = {}\n\n        user_session = user_sessions[context.session.id]\n\n        # Copy important fields from the session\n        user_session[\"id\"] = context.session.id\n        user_session[\"env\"] = context.session.user_env\n        user_session[\"chat_settings\"] = context.session.chat_settings\n        user_session[\"user\"] = context.session.user\n        user_session[\"chat_profile\"] = context.session.chat_profile\n        user_session[\"client_type\"] = context.session.client_type\n\n        return user_session.get(key, default)\n\n    def set(self, key, value):\n        if not context.session:\n            return None\n\n        if context.session.id not in user_sessions:\n            user_sessions[context.session.id] = {}\n\n        user_session = user_sessions[context.session.id]\n        user_session[key] = value\n\n    def create_accessor(\n        self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None\n    ) -> \"SessionAccessor[T]\":\n        \"\"\"\n        Create a typed session accessor object for the given key and default value.\n\n        #### Note: Creates the accessor configuration. The session value itself is only stored/updated when `.set()`, `.reset()`, or `.apply()` are called.\n\n        Parameters\n        ----------\n        key : str\n            The session dictionary key to store the value under\n        default : T\n            Default value to return when key is not present in session\n        apply_fn : Optional[Callable[[T], T]], default None\n            Optional function to transform the value when apply() is called\n\n        Returns\n        -------\n        SessionAccessor[T]\n            A typed accessor object bound to the specified session key\n\n        Examples\n        --------\n\n        ```python\n        count = cl.user_session.create_accessor(\"count\", 0)\n        count.get() # returns 0\n        count.set(5)  # type-safe setter\n        count.get() # returns 5\n\n        # With transform function\n        counter = cl.user_session.create_accessor(\"counter\", 0, apply_fn=lambda x: x + 1)\n        counter.apply() # increments value and returns new value (1)\n\n        @cl.on_message\n        async def on_message(message: cl.Message):\n            await cl.Message(content=f\"You sent {counter.apply()} messages\").send() # You sent 2 messages\n        ```\n        \"\"\"\n        return SessionAccessor(key, default, apply_fn=apply_fn)\n\n\nuser_session = UserSession()\n\n\nclass SessionAccessor(Generic[T]):\n    \"\"\"\n    Extended session accessor class to store user specific data between calls with type safety.\n\n    Provides a typed wrapper around user_session dictionary access with default values\n    and optional transform functions. The session value is only stored in memory when\n    explicitly modified through `.set()`, `.reset()`, or `.apply()` methods.\n\n    Examples\n    --------\n    ```python\n    count = cl.user_session.create_accessor(\"count\", 0)\n    count.get() # returns 0\n    count.set(5)  # type-safe setter\n    count.get() # returns 5\n\n    # With transform function\n    counter = cl.user_session.create_accessor(\"counter\", 0, apply_fn=lambda x: x + 1)\n    counter.apply() # increments value and returns new value (1)\n\n    @cl.on_message\n    async def on_message(message: cl.Message):\n        await cl.Message(content=f\"You sent {counter.apply()} messages\").send() # You sent 2 messages\n    ```\n    \"\"\"\n\n    def __init__(\n        self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None\n    ):\n        self._key = key\n        self._default = default\n        self._apply_fn = apply_fn\n\n    def get(self) -> T:\n        \"\"\"\n        Get the current value of the accessor.\n        \"\"\"\n        return user_session.get(self._key, self._default)\n\n    def set(self, value: T) -> None:\n        \"\"\"\n        Set the value of the accessor.\n        \"\"\"\n        return user_session.set(self._key, value)\n\n    def reset(self) -> None:\n        \"\"\"\n        Reset the value to the default.\n        \"\"\"\n        return self.set(self._default)\n\n    def apply(self) -> T:\n        \"\"\"\n        Apply the transform function to the current value, store the result, and return it.\n\n        Returns the current value if no transform function is provided.\n        \"\"\"\n        value = self.get()\n        if self._apply_fn:\n            value = self._apply_fn(value)\n        self.set(value)\n        return value\n"
  },
  {
    "path": "backend/chainlit/utils.py",
    "content": "import functools\nimport importlib\nimport inspect\nimport os\nfrom asyncio import CancelledError\nfrom datetime import datetime, timezone\nfrom typing import Callable\n\nimport click\nfrom fastapi import FastAPI, Request\nfrom fastapi.responses import JSONResponse\nfrom packaging import version\nfrom starlette.middleware.base import BaseHTTPMiddleware\n\nfrom chainlit.auth import ensure_jwt_secret\nfrom chainlit.context import context\nfrom chainlit.logger import logger\n\n\ndef utc_now():\n    dt = datetime.now(timezone.utc).replace(tzinfo=None)\n    return dt.isoformat() + \"Z\"\n\n\ndef timestamp_utc(timestamp: float):\n    dt = datetime.fromtimestamp(timestamp, timezone.utc).replace(tzinfo=None)\n    return dt.isoformat() + \"Z\"\n\n\ndef wrap_user_function(user_function: Callable, with_task=False) -> Callable:\n    \"\"\"\n    Wraps a user-defined function to accept arguments as a dictionary.\n\n    Args:\n        user_function (Callable): The user-defined function to wrap.\n\n    Returns:\n        Callable: The wrapped function.\n    \"\"\"\n\n    @functools.wraps(user_function)\n    async def wrapper(*args):\n        # Get the parameter names of the user-defined function\n        user_function_params = list(inspect.signature(user_function).parameters.keys())\n\n        # Create a dictionary of parameter names and their corresponding values from *args\n        params_values = {\n            param_name: arg for param_name, arg in zip(user_function_params, args)\n        }\n\n        if with_task:\n            await context.emitter.task_start()\n\n        try:\n            # Call the user-defined function with the arguments\n            if inspect.iscoroutinefunction(user_function):\n                return await user_function(**params_values)\n            else:\n                return user_function(**params_values)\n        except CancelledError:\n            pass\n        except Exception as e:\n            logger.exception(e)\n            if with_task:\n                from chainlit.message import ErrorMessage\n\n                await ErrorMessage(\n                    content=str(e) or e.__class__.__name__, author=\"Error\"\n                ).send()\n        finally:\n            if with_task:\n                await context.emitter.task_end()\n\n    return wrapper\n\n\ndef make_module_getattr(registry):\n    \"\"\"Leverage PEP 562 to make imports lazy in an __init__.py\n\n    The registry must be a dictionary with the items to import as keys and the\n    modules they belong to as a value.\n    \"\"\"\n\n    def __getattr__(name):\n        module_path = registry[name]\n        module = importlib.import_module(module_path, __package__)\n        return getattr(module, name)\n\n    return __getattr__\n\n\ndef check_module_version(name, required_version):\n    \"\"\"\n    Check the version of a module.\n\n    Args:\n        name (str): A module name.\n        version (str): Minimum version.\n\n    Returns:\n        (bool): Return True if the module is installed and the version\n            match the minimum required version.\n    \"\"\"\n    try:\n        module = importlib.import_module(name)\n    except ModuleNotFoundError:\n        return False\n    return version.parse(module.__version__) >= version.parse(required_version)\n\n\ndef check_file(target: str):\n    # Define accepted file extensions for Chainlit\n    ACCEPTED_FILE_EXTENSIONS = (\"py\", \"py3\")\n\n    _, extension = os.path.splitext(target)\n\n    # Check file extension\n    if extension[1:] not in ACCEPTED_FILE_EXTENSIONS:\n        if extension[1:] == \"\":\n            raise click.BadArgumentUsage(\n                \"Chainlit requires raw Python (.py) files, but the provided file has no extension.\"\n            )\n        else:\n            raise click.BadArgumentUsage(\n                f\"Chainlit requires raw Python (.py) files, not {extension}.\"\n            )\n\n    if not os.path.exists(target):\n        raise click.BadParameter(f\"File does not exist: {target}\")\n\n\ndef mount_chainlit(app: FastAPI, target: str, path=\"/chainlit\"):\n    from chainlit.config import config, load_module\n    from chainlit.server import app as chainlit_app\n\n    config.run.debug = os.environ.get(\"CHAINLIT_DEBUG\", False)\n    os.environ[\"CHAINLIT_ROOT_PATH\"] = path\n\n    api_full_path = path\n\n    if app.root_path:\n        parent_root_path = app.root_path.rstrip(\"/\")\n        api_full_path = parent_root_path + path\n        os.environ[\"CHAINLIT_PARENT_ROOT_PATH\"] = parent_root_path\n\n    check_file(target)\n    # Load the module provided by the user\n    config.run.module_name = target\n    load_module(config.run.module_name)\n\n    ensure_jwt_secret()\n\n    class ChainlitMiddleware(BaseHTTPMiddleware):\n        \"\"\"Middleware to handle path routing for submounted Chainlit applications.\n\n        When Chainlit is submounted within a larger FastAPI application, its default route\n        `@router.get(\"/{full_path:path}\")` can conflict with the main app's routing. This\n        middleware ensures requests are only forwarded to Chainlit if they match the\n        designated subpath, preventing routing collisions.\n\n        If a request's path doesn't start with the configured subpath, the middleware\n        returns a 404 response instead of forwarding to Chainlit's default route.\n        \"\"\"\n\n        async def dispatch(self, request: Request, call_next):\n            if not request.url.path.startswith(api_full_path):\n                return JSONResponse(status_code=404, content={\"detail\": \"Not found\"})\n\n            return await call_next(request)\n\n    chainlit_app.add_middleware(ChainlitMiddleware)\n\n    app.mount(path, chainlit_app)\n"
  },
  {
    "path": "backend/chainlit/version.py",
    "content": "__version__ = \"2.10.0\"\n"
  },
  {
    "path": "backend/pyproject.toml",
    "content": "[project]\nname = \"chainlit\"\ndynamic = [\"version\"]\nkeywords = [\n    \"LLM\",\n    \"Agents\",\n    \"MCP\",\n    \"gen ai\",\n    \"chat ui\",\n    \"chatbot ui\",\n    \"openai\",\n    \"copilot\",\n    \"langchain\",\n    \"conversational ai\",\n]\ndescription = \"Build Conversational AI.\"\nauthors = [\n    { name = \"Willy Douhard\" },\n    { name = \"Dan Andre Constantini\" }\n]\nlicense = { text = \"Apache-2.0\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10,<4.0.0\"\nclassifiers = [\n    \"Framework :: FastAPI\",\n    \"Topic :: Scientific/Engineering :: Artificial Intelligence\",\n    \"Topic :: Communications :: Chat\",\n    \"Programming Language :: JavaScript\",\n    \"Topic :: Software Development :: User Interfaces\",\n    \"Topic :: Software Development :: Libraries :: Python Modules\",\n    \"Environment :: Web Environment\",\n]\n\ndependencies = [\n    \"httpx>=0.23.0\",\n    \"literalai==0.1.201\",\n    \"dataclasses_json>=0.6.7,<0.7.0\",\n    \"fastapi>=0.116.1\",\n    \"starlette>=0.47.2\",\n    \"uvicorn>=0.35.0\",\n    \"python-socketio>=5.11.0,<6.0.0\",\n    \"aiofiles>=23.1.0,<25.0.0\",\n    \"syncer>=2.0.3,<3.0.0\",\n    \"asyncer>=0.0.8,<0.1.0\",\n    \"mcp>=1.11.0,<2.0.0\",\n    \"nest-asyncio>=1.6.0,<2.0.0\",\n    \"click>=8.1.3,<9.0.0\",\n    \"tomli>=2.0.1,<3.0.0\",\n    \"pydantic>=2.7.2,<3\",\n    \"python-dotenv>=1.0.0,<2.0.0\",\n    \"watchfiles>=1.1.1,<2.0.0\",\n    \"filetype>=1.2.0,<2.0.0\",\n    \"lazify>=0.4.0,<0.5.0\",\n    \"packaging>=23.1\",\n    \"python-multipart>=0.0.18,<1.0.0\",\n    \"pyjwt>=2.8.0,<3.0.0\",\n    \"audioop-lts>=0.2.1,<0.3.0; python_version>='3.13'\",\n    \"pydantic-settings>=2.10.1\"\n]\n\n[project.urls]\nHomepage = \"https://chainlit.io/\"\nDocumentation = \"https://docs.chainlit.io/\"\nRepository = \"https://github.com/Chainlit/chainlit\"\n\n[project.scripts]\nchainlit = \"chainlit.cli:cli\"\n\n[project.optional-dependencies]\ntests = [\n    \"pytest>=8.3.2,<9.0.0\",\n    \"pytest-asyncio>=0.23.8,<1.0.0\",\n    \"pytest-cov>=5.0.0,<6.0.0\",\n    \"openai>=1.11.1,<2.0.0\",\n    \"langchain>=0.2.4,<0.3.0\",\n    \"llama-index>=0.13.0,<1.0.0\",\n    \"semantic-kernel>=1.24.0,<2.0.0\",\n    \"tenacity>=8.4.1,<9.0.0\",\n    \"transformers>=4.38,<5.0\",\n    \"matplotlib>=3.7.1,<4.0.0\",\n    \"plotly>=5.18.0,<6.0.0\",\n    \"slack_bolt>=1.18.1,<2.0.0\",\n    \"discord>=2.3.2,<3.0.0\",\n    \"botbuilder-core>=4.15.0,<5.0.0\",\n    \"aiosqlite>=0.20.0,<1.0.0\",\n    \"pandas>=2.2.2,<3.0.0\",\n    \"moto>=5.0.14,<6.0.0\",\n]\ndev = [\n    \"ruff>=0.9.0,<1.0.0\",\n]\nmypy = [\n    \"mypy>=1.13,<2.0.0\",\n    \"types-requests>=2.31.0.2,<3.0.0\",\n    \"types-aiofiles>=23.1.0.5,<24.0.0\",\n    \"mypy-boto3-dynamodb>=1.34.113,<2.0.0\",\n    \"pandas-stubs>=2.2.2,<3.0.0; python_version>='3.9'\",\n]\ncustom-data = [\n    \"asyncpg>=0.30.0,<1.0.0\",\n    \"SQLAlchemy>=2.0.28,<3.0.0\",\n    \"boto3>=1.34.73,<2.0.0\",\n    \"azure-identity>=1.14.1,<2.0.0\",\n    \"azure-storage-file-datalake>=12.14.0,<13.0.0\",\n    \"azure-storage-blob>=12.24.0,<13.0.0\",\n    \"google-cloud-storage>=2.19.0,<3.0.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build]\nexclude = [\n  \"chainlit/frontend/**/*\",\n  \"chainlit/copilot/**/**/\"\n]\n\n[tool.hatch.build.hooks.custom]\npath = \"build.py\"\n\n[tool.hatch.build.targets.sdist]\nartifacts = [\n    \"chainlit/frontend/dist/**/*\",\n    \"chainlit/copilot/dist/**/*\"\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"chainlit\"]\nartifacts = [\n    \"chainlit/frontend/dist/**/*\",\n    \"chainlit/copilot/dist/**/*\"\n]\n\n[tool.hatch.version]\npath = \"chainlit/version.py\"\n\n[tool.mypy]\npython_version = \"3.10\"\n\n[[tool.mypy.overrides]]\nmodule = [\n    \"boto3.dynamodb.types\",\n    \"botbuilder.*\",\n    \"filetype\",\n    \"langflow\",\n    \"lazify\",\n    \"plotly\",\n    \"nest_asyncio\",\n    \"socketio.*\",\n    \"syncer\",\n    \"azure.storage.filedatalake\",\n    \"google-cloud-storage\",\n    \"azure.storage.blob\",\n    \"azure.storage.blob.aio\",\n    \"google.auth\",\n    \"google.oauth2\",\n]\n\nignore_missing_imports = true\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\nasyncio_mode = \"auto\"\n\n[tool.ruff]\ntarget-version = \"py39\"\n\n[tool.ruff.lint]\nselect = [\n    \"E\",\n    \"F\",\n    \"I\",\n    \"LOG\",\n    \"UP\",\n    \"T10\",\n    \"ISC\",\n    \"ICN\",\n    \"LOG\",\n    \"G\",\n    \"PIE\",\n    \"PT\",\n    \"Q\",\n    \"RSE\",\n    \"FURB\",\n    \"RUF\",\n]\nignore = [\n    \"E712\",\n    \"E501\",\n    \"UP006\",\n    \"UP007\",\n    \"UP035\",\n    \"UP045\",\n    \"PIE790\",\n    \"RUF005\",\n    \"RUF006\",\n    \"RUF012\",\n    \"ISC001\",\n]\n\n[tool.ruff.lint.isort]\ncombine-as-imports = true\n"
  },
  {
    "path": "backend/tests/__init__.py",
    "content": "\n"
  },
  {
    "path": "backend/tests/auth/__init__.py",
    "content": ""
  },
  {
    "path": "backend/tests/auth/test_cookie.py",
    "content": "import importlib\n\nimport pytest\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\nimport chainlit.auth.cookie as cookie_module\nfrom chainlit.auth import (\n    clear_auth_cookie,\n    get_token_from_cookies,\n    set_auth_cookie,\n)\n\n\n@pytest.fixture\ndef test_app():\n    app = FastAPI()\n\n    @app.post(\"/set-cookie\")\n    async def set_cookie_endpoint(request: Request, token: str = Form()):\n        response = Response()\n        set_auth_cookie(request, response, token)\n        return response\n\n    @app.get(\"/get-token\")\n    async def get_token_endpoint(request: Request):\n        token = get_token_from_cookies(request.cookies)\n        return {\"token\": token}\n\n    @app.delete(\"/clear-cookie\")\n    async def clear_cookie_endpoint(request: Request):\n        response = Response()\n        clear_auth_cookie(request, response)\n        return response\n\n    return app\n\n\n@pytest.fixture\ndef client(test_app):\n    return TestClient(test_app)\n\n\ndef test_short_token(client):\n    \"\"\"Test with a <3000 shorter token.\"\"\"\n\n    # Set a short token\n    short_token = \"x\" * 1000\n    set_response = client.post(\"/set-cookie\", data={\"token\": short_token})\n    assert set_response.status_code == 200\n\n    # Verify cookies were set\n    cookies = set_response.cookies\n    assert cookies, \"No cookies set\"\n    assert \"access_token\" in cookies, f\"No chunking for short cookies: {cookies}\"\n\n    # Read back the token using client's cookie jar\n    get_response = client.get(\"/get-token\")\n    assert get_response.status_code == 200\n    assert get_response.json()[\"token\"] == short_token\n\n\ndef test_set_and_read_4kb_token(client):\n    \"\"\"Test full cookie lifecycle using actual client cookie handling.\"\"\"\n\n    # Set a 4KB token\n    token_4kb = \"x\" * 4000\n    set_response = client.post(\"/set-cookie\", data={\"token\": token_4kb})\n    assert set_response.status_code == 200\n\n    # Verify cookies were set\n    cookies = set_response.cookies\n    assert f\"{cookies.keys()} should contain chunked cookies\", any(\n        key.startswith(\"access_token_\") for key in cookies.keys()\n    )\n\n    # Read back the token using client's cookie jar\n    get_response = client.get(\"/get-token\")\n    assert get_response.status_code == 200\n\n    response_token = get_response.json()[\"token\"]\n    assert len(response_token) == len(token_4kb)\n    assert response_token == token_4kb\n\n\ndef test_overwrite_shorter_token_chunked(client):\n    \"\"\"Test cookie chunk cleanup when replacing a large token with a smaller one.\"\"\"\n    # Set initial long token\n    long_token = \"LONG\" * 2000  # 8000 characters\n    client.post(\"/set-cookie\", data={\"token\": long_token})\n\n    # Verify initial chunks exist\n    first_cookies = client.cookies\n    assert len([k for k in first_cookies if k.startswith(\"access_token_\")]) > 1\n\n    # Set shorter token (should clear previous chunks)\n    short_token = \"SHORT\" * 1000  # 4000 characters\n    client.post(\"/set-cookie\", data={\"token\": short_token})\n\n    # Verify new cookie state\n    final_response = client.get(\"/get-token\")\n    assert final_response.json()[\"token\"] == short_token\n\n    # Verify only two chunks remain\n    final_cookies = client.cookies\n    chunk_cookies = [k for k in final_cookies if k.startswith(\"access_token_\")]\n    assert len(chunk_cookies) == 2, f\"Found {len(chunk_cookies)} residual cookies\"\n\n\ndef test_overwrite_shorter_token_unchunked(client):\n    \"\"\"Test cookie chunk cleanup when replacing a large token with a smaller one.\"\"\"\n    # Set initial long token\n    long_token = \"LONG\" * 1000  # 4000 characters\n    client.post(\"/set-cookie\", data={\"token\": long_token})\n\n    # Verify initial chunks exist\n    first_cookies = client.cookies\n    assert len([k for k in first_cookies if k.startswith(\"access_token_\")]) > 1\n\n    # Set shorter token (should clear previous chunks)\n    short_token = \"SHORT\"\n    client.post(\"/set-cookie\", data={\"token\": short_token})\n\n    # Verify new cookie state\n    final_response = client.get(\"/get-token\")\n    assert final_response.json()[\"token\"] == short_token\n\n    # Verify no chunks remain\n    final_cookies = client.cookies\n    chunk_cookies = [k for k in final_cookies if k.startswith(\"access_token_\")]\n    assert len(chunk_cookies) == 0, f\"Found {len(chunk_cookies)} residual cookies\"\n\n\ndef test_state_cookie_lifetime_default(monkeypatch):\n    \"\"\"Test that _state_cookie_lifetime defaults to 180 seconds (3 minutes).\"\"\"\n    monkeypatch.delenv(\"CHAINLIT_STATE_COOKIE_LIFETIME\", raising=False)\n    importlib.reload(cookie_module)\n    assert cookie_module._state_cookie_lifetime == 180\n\n\ndef test_state_cookie_lifetime_custom(monkeypatch):\n    \"\"\"Test that _state_cookie_lifetime can be set via environment variable.\"\"\"\n    monkeypatch.setenv(\"CHAINLIT_STATE_COOKIE_LIFETIME\", \"600\")\n    importlib.reload(cookie_module)\n    assert cookie_module._state_cookie_lifetime == 600\n\n\ndef test_clear_auth_cookie(client):\n    \"\"\"Test cookie clearing removes all chunks.\"\"\"\n    # Set initial token\n    client.post(\"/set-cookie\", data={\"token\": \"x\" * 4000})\n\n    # Verify cookies exist\n    assert len(client.cookies) > 0\n\n    # Clear cookies\n    clear_response = client.delete(\"/clear-cookie\")\n    assert clear_response.status_code == 200\n\n    # Verify cookies were cleared\n    assert len(clear_response.cookies) == 0\n    final_response = client.get(\"/get-token\")\n    assert final_response.json()[\"token\"] is None\n"
  },
  {
    "path": "backend/tests/conftest.py",
    "content": "import datetime\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\nfrom typing import Callable\nfrom unittest.mock import AsyncMock, Mock\n\nimport pytest\nimport pytest_asyncio\n\nfrom chainlit import config\nfrom chainlit.callbacks import data_layer\nfrom chainlit.context import ChainlitContext, context_var\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.session import HTTPSession, WebsocketSession\nfrom chainlit.user import PersistedUser\nfrom chainlit.user_session import UserSession\n\n\n@pytest.fixture\ndef persisted_test_user():\n    return PersistedUser(\n        id=\"test_user_id\",\n        createdAt=datetime.datetime.now().isoformat(),\n        identifier=\"test_user_identifier\",\n    )\n\n\n@pytest.fixture\ndef mock_session_factory(persisted_test_user: PersistedUser) -> Callable[..., Mock]:\n    def create_mock_session(**kwargs) -> Mock:\n        mock = Mock(spec=WebsocketSession)\n        mock.user = kwargs.get(\"user\", persisted_test_user)\n        mock.id = kwargs.get(\"id\", \"test_session_id\")\n        mock.user_env = kwargs.get(\"user_env\", {\"test_env\": \"value\"})\n        mock.chat_settings = kwargs.get(\"chat_settings\", {})\n        mock.chat_profile = kwargs.get(\"chat_profile\", None)\n        mock.environ = kwargs.get(\"environ\", None)\n        mock.client_type = kwargs.get(\"client_type\", \"webapp\")\n        mock.thread_id = kwargs.get(\"thread_id\", \"test_thread_id\")\n        mock.emit = AsyncMock()\n        mock.has_first_interaction = kwargs.get(\"has_first_interaction\", True)\n        mock.files = kwargs.get(\"files\", {})\n        mock.files_spec = kwargs.get(\"files_spec\", {})\n\n        return mock\n\n    return create_mock_session\n\n\n@pytest.fixture\ndef mock_session(mock_session_factory) -> Mock:\n    return mock_session_factory()\n\n\n@asynccontextmanager\nasync def create_chainlit_context(mock_session):\n    from chainlit.emitter import BaseChainlitEmitter\n\n    # Create a mock emitter with all necessary methods\n    mock_emitter = Mock(spec=BaseChainlitEmitter)\n    mock_emitter.send_step = AsyncMock()\n    mock_emitter.update_step = AsyncMock()\n    mock_emitter.delete_step = AsyncMock()\n    mock_emitter.stream_start = AsyncMock()\n    mock_emitter.send_element = AsyncMock()\n    mock_emitter.send_action = AsyncMock()\n    mock_emitter.remove_action = AsyncMock()\n    mock_emitter.emit = AsyncMock()\n    mock_emitter.set_chat_settings = Mock()  # Sync method, not async\n\n    context = ChainlitContext(mock_session, emitter=mock_emitter)\n    token = context_var.set(context)\n    try:\n        yield context\n    finally:\n        context_var.reset(token)\n\n\n@pytest_asyncio.fixture\nasync def mock_chainlit_context(persisted_test_user, mock_session):\n    mock_session.user = persisted_test_user\n    return create_chainlit_context(mock_session)\n\n\n@pytest.fixture\ndef user_session():\n    return UserSession()\n\n\n@pytest.fixture\ndef mock_websocket_session():\n    session = Mock(spec=WebsocketSession)\n    session.emit = AsyncMock()\n\n    return session\n\n\n@pytest.fixture\ndef mock_http_session():\n    return Mock(spec=HTTPSession)\n\n\n@pytest.fixture\ndef mock_data_layer(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:\n    mock_data_layer = AsyncMock(spec=BaseDataLayer)\n\n    return mock_data_layer\n\n\n@pytest.fixture\ndef mock_get_data_layer(mock_data_layer: AsyncMock, test_config: config.ChainlitConfig):\n    # Instantiate mock data layer\n    mock_get_data_layer = Mock(return_value=mock_data_layer)\n\n    # Configure it using @data_layer decorator\n    return data_layer(mock_get_data_layer)\n\n\n@pytest.fixture\ndef test_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):\n    monkeypatch.setenv(\"CHAINLIT_ROOT_PATH\", str(tmp_path))\n\n    test_config = config.load_config()\n\n    monkeypatch.setattr(\"chainlit.callbacks.config\", test_config)\n    monkeypatch.setattr(\"chainlit.server.config\", test_config)\n    monkeypatch.setattr(\"chainlit.config.config\", test_config)\n\n    return test_config\n"
  },
  {
    "path": "backend/tests/data/__init__.py",
    "content": ""
  },
  {
    "path": "backend/tests/data/conftest.py",
    "content": "from unittest.mock import AsyncMock\n\nimport pytest\n\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.user import User\n\n\n@pytest.fixture\ndef mock_storage_client():\n    mock_client = AsyncMock(spec=BaseStorageClient)\n    mock_client.upload_file.return_value = {\n        \"url\": \"https://example.com/test.txt\",\n        \"object_key\": \"test_user/test_element/test.txt\",\n    }\n    return mock_client\n\n\n@pytest.fixture\ndef test_user() -> User:\n    return User(identifier=\"test_user_identifier\", metadata={})\n"
  },
  {
    "path": "backend/tests/data/storage_clients/test_gcs.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom chainlit.data.storage_clients.base import storage_expiry_time\nfrom chainlit.data.storage_clients.gcs import GCSStorageClient\n\n\n@pytest.fixture\ndef mock_gcs_client():\n    \"\"\"Create a mock Google Cloud Storage client.\"\"\"\n    # First mock the service_account\n    with patch(\n        \"chainlit.data.storage_clients.gcs.service_account\"\n    ) as mock_service_account:\n        mock_credentials = MagicMock()\n        mock_service_account.Credentials.from_service_account_info.return_value = (\n            mock_credentials\n        )\n\n        # Then mock the storage client\n        with patch(\"chainlit.data.storage_clients.gcs.storage\") as mock_storage:\n            mock_client = MagicMock()\n            mock_storage.Client.return_value = mock_client\n            mock_bucket = MagicMock()\n            mock_client.bucket.return_value = mock_bucket\n            mock_blob = MagicMock()\n            mock_bucket.blob.return_value = mock_blob\n\n            yield {\n                \"storage\": mock_storage,\n                \"client\": mock_client,\n                \"bucket\": mock_bucket,\n                \"blob\": mock_blob,\n                \"service_account\": mock_service_account,\n                \"credentials\": mock_credentials,\n            }\n\n\nclass TestGCSStorageClient:\n    def test_init(self, mock_gcs_client):\n        \"\"\"Test initialization of GCS client.\"\"\"\n        # Remove client assignment, directly call the constructor\n        GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Verify service account credentials were created correctly\n        mock_gcs_client[\n            \"service_account\"\n        ].Credentials.from_service_account_info.assert_called_once_with(\n            {\n                \"type\": \"service_account\",\n                \"project_id\": \"test-project\",\n                \"private_key\": \"test-key\",\n                \"client_email\": \"test@example.com\",\n                \"token_uri\": \"https://oauth2.googleapis.com/token\",\n            }\n        )\n\n        # Verify storage client was initialized with credentials\n        mock_gcs_client[\"storage\"].Client.assert_called_once_with(\n            project=\"test-project\", credentials=mock_gcs_client[\"credentials\"]\n        )\n\n        # Verify bucket was retrieved\n        mock_gcs_client[\"client\"].bucket.assert_called_once_with(\"test-bucket\")\n\n    def test_sync_get_read_url(self, mock_gcs_client):\n        \"\"\"Test generating a signed URL.\"\"\"\n        # Set up the mock to return a URL\n        mock_gcs_client[\n            \"blob\"\n        ].generate_signed_url.return_value = \"https://signed-url.example.com\"\n\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset mocks to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Test the method\n        url = client.sync_get_read_url(\"test/path/file.txt\")\n\n        # Verify correct methods were called\n        mock_gcs_client[\"bucket\"].blob.assert_called_once_with(\"test/path/file.txt\")\n        mock_gcs_client[\"blob\"].generate_signed_url.assert_called_once_with(\n            version=\"v4\", expiration=storage_expiry_time, method=\"GET\"\n        )\n\n        assert url == \"https://signed-url.example.com\"\n\n    @pytest.mark.asyncio\n    async def test_get_read_url(self, mock_gcs_client):\n        \"\"\"Test the async wrapper for getting a read URL.\"\"\"\n        # Set up the mock to return a URL\n        mock_gcs_client[\n            \"blob\"\n        ].generate_signed_url.return_value = \"https://signed-url.example.com\"\n\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset mocks to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Test the async method\n        url = await client.get_read_url(\"test/path/file.txt\")\n\n        # Verify correct methods were called\n        mock_gcs_client[\"bucket\"].blob.assert_called_once_with(\"test/path/file.txt\")\n        mock_gcs_client[\"blob\"].generate_signed_url.assert_called_once_with(\n            version=\"v4\", expiration=storage_expiry_time, method=\"GET\"\n        )\n\n        assert url == \"https://signed-url.example.com\"\n\n    def test_sync_upload_file(self, mock_gcs_client):\n        \"\"\"Test uploading a file to GCS.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Mock the bucket name property\n        mock_gcs_client[\"bucket\"].name = \"test-bucket\"\n\n        # Test with binary data\n        binary_data = b\"test content\"\n        object_key = \"test/path/file.txt\"\n\n        # Remove the unused result assignment\n        client.sync_upload_file(\n            object_key=object_key, data=binary_data, mime=\"text/plain\", overwrite=True\n        )\n\n        # Check that the blob was called with the correct object key (using assert_any_call instead of assert_called_once_with)\n        mock_gcs_client[\"bucket\"].blob.assert_any_call(object_key)\n        mock_gcs_client[\"blob\"].upload_from_string.assert_called_once_with(\n            binary_data, content_type=\"text/plain\"\n        )\n\n    def test_sync_upload_file_string_data(self, mock_gcs_client):\n        \"\"\"Test uploading string data to GCS.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n        mock_gcs_client[\"bucket\"].name = \"test-bucket\"\n\n        # Test with string data that should be encoded\n        string_data = \"test content\"\n        object_key = \"test/path/file.txt\"\n\n        # Remove the unused result assignment\n        client.sync_upload_file(\n            object_key=object_key, data=string_data, mime=\"text/plain\", overwrite=True\n        )\n\n        # Check that the correct methods were called\n        mock_gcs_client[\"bucket\"].blob.assert_any_call(object_key)\n        mock_gcs_client[\"blob\"].upload_from_string.assert_called_once_with(\n            b\"test content\", content_type=\"text/plain\"\n        )\n\n    def test_sync_upload_file_no_overwrite(self, mock_gcs_client):\n        \"\"\"Test upload with overwrite=False and existing file.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Configure blob to indicate file exists\n        mock_gcs_client[\"blob\"].exists.return_value = True\n\n        with pytest.raises(\n            Exception,\n            match=r\"Failed to upload file to GCS: File test/path/existing\\.txt already exists and overwrite is False\",\n        ):\n            client.sync_upload_file(\n                object_key=\"test/path/existing.txt\",\n                data=b\"test content\",\n                overwrite=False,\n            )\n\n        mock_gcs_client[\"blob\"].exists.assert_called_once()\n        mock_gcs_client[\"blob\"].upload_from_string.assert_not_called()\n\n    def test_sync_upload_file_error(self, mock_gcs_client):\n        \"\"\"Test error handling during upload.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Configure upload to throw an exception\n        mock_gcs_client[\"blob\"].upload_from_string.side_effect = ValueError(\n            \"Upload failed\"\n        )\n\n        with pytest.raises(\n            Exception, match=\"Failed to upload file to GCS: Upload failed\"\n        ):\n            client.sync_upload_file(\n                object_key=\"test/path/file.txt\", data=b\"test content\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_upload_file(self, mock_gcs_client):\n        \"\"\"Test the async wrapper for uploading a file.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n        mock_gcs_client[\"bucket\"].name = \"test-bucket\"\n\n        # Test with binary data\n        binary_data = b\"test content\"\n        object_key = \"test/path/file.txt\"\n\n        # Remove the unused result assignment\n        await client.upload_file(\n            object_key=object_key, data=binary_data, mime=\"text/plain\", overwrite=True\n        )\n\n        # Check that the correct methods were called\n        mock_gcs_client[\"bucket\"].blob.assert_any_call(object_key)\n        mock_gcs_client[\"blob\"].upload_from_string.assert_called_once_with(\n            binary_data, content_type=\"text/plain\"\n        )\n\n    def test_sync_delete_file(self, mock_gcs_client):\n        \"\"\"Test deleting a file from GCS.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Test successful delete\n        result = client.sync_delete_file(\"test/path/file.txt\")\n\n        mock_gcs_client[\"bucket\"].blob.assert_called_once_with(\"test/path/file.txt\")\n        mock_gcs_client[\"blob\"].delete.assert_called_once()\n        assert result is True\n\n    def test_sync_delete_file_error(self, mock_gcs_client):\n        \"\"\"Test error handling during file deletion.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Configure delete to throw an exception\n        mock_gcs_client[\"blob\"].delete.side_effect = ValueError(\"Delete failed\")\n\n        # The method should catch the exception and return False\n        result = client.sync_delete_file(\"test/path/file.txt\")\n\n        mock_gcs_client[\"bucket\"].blob.assert_called_once_with(\"test/path/file.txt\")\n        mock_gcs_client[\"blob\"].delete.assert_called_once()\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_delete_file(self, mock_gcs_client):\n        \"\"\"Test the async wrapper for deleting a file.\"\"\"\n        client = GCSStorageClient(\n            bucket_name=\"test-bucket\",\n            project_id=\"test-project\",\n            client_email=\"test@example.com\",\n            private_key=\"test-key\",\n        )\n\n        # Reset the mock to ensure clean state\n        mock_gcs_client[\"bucket\"].reset_mock()\n        mock_gcs_client[\"blob\"].reset_mock()\n\n        # Test successful delete\n        result = await client.delete_file(\"test/path/file.txt\")\n\n        mock_gcs_client[\"bucket\"].blob.assert_called_once_with(\"test/path/file.txt\")\n        mock_gcs_client[\"blob\"].delete.assert_called_once()\n        assert result is True\n"
  },
  {
    "path": "backend/tests/data/storage_clients/test_s3.py",
    "content": "import os\n\nimport boto3  # type: ignore\nimport pytest\nfrom moto import mock_aws\n\nfrom chainlit.data.storage_clients.s3 import S3StorageClient\n\n\n# Fixtures for setting up the DynamoDB table\n@pytest.fixture\ndef aws_credentials():\n    \"\"\"Mocked AWS Credentials for moto.\"\"\"\n    os.environ[\"AWS_ACCESS_KEY_ID\"] = \"testing\"\n    os.environ[\"AWS_SECRET_ACCESS_KEY\"] = \"testing\"\n    os.environ[\"AWS_SECURITY_TOKEN\"] = \"testing\"\n    os.environ[\"AWS_SESSION_TOKEN\"] = \"testing\"\n    os.environ[\"AWS_DEFAULT_REGION\"] = \"us-east-1\"\n\n\n@pytest.fixture\ndef s3_mock(aws_credentials):\n    \"\"\"Moto mock S3 setup.\"\"\"\n    with mock_aws():\n        s3 = boto3.client(\"s3\", region_name=\"us-east-1\")\n        # Create a mock bucket\n        s3.create_bucket(Bucket=\"my-test-bucket\")\n        yield s3\n\n\n@pytest.mark.asyncio\nasync def test_upload_file(s3_mock):\n    # Initialize the S3StorageClient with the mock bucket\n    client = S3StorageClient(bucket=\"my-test-bucket\")\n\n    # Call the upload_file method and await the result\n    result = await client.upload_file(\n        object_key=\"test.txt\", data=\"This is a test file\", mime=\"text/plain\"\n    )\n\n    # Assert that the file upload returned the correct URL\n    assert result[\"object_key\"] == \"test.txt\"\n    assert result[\"url\"] == \"https://my-test-bucket.s3.amazonaws.com/test.txt\"\n\n    # Verify that the file exists in the mock S3\n    response = s3_mock.get_object(Bucket=\"my-test-bucket\", Key=\"test.txt\")\n    assert response[\"Body\"].read().decode() == \"This is a test file\"\n"
  },
  {
    "path": "backend/tests/data/test_chainlit_data_layer.py",
    "content": "import json\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom chainlit.data.chainlit_data_layer import ChainlitDataLayer\n\n\n@pytest.mark.asyncio\nasync def test_update_thread_preserves_metadata_when_none():\n    \"\"\"Test that update_thread does not overwrite existing metadata when metadata=None.\"\"\"\n    # Create a mock data layer\n    data_layer = ChainlitDataLayer(\n        database_url=\"postgresql://test\", storage_client=None, show_logger=False\n    )\n\n    # Mock the execute_query method\n    data_layer.execute_query = AsyncMock()\n\n    # Simulate calling update_thread with only a name, metadata=None (default)\n    await data_layer.update_thread(thread_id=\"test-thread-123\", name=\"Updated Name\")\n\n    # Verify execute_query was called\n    assert data_layer.execute_query.called\n\n    # Get the query and params from the call\n    call_args = data_layer.execute_query.call_args\n    query = call_args[0][0]\n    params = call_args[0][1]\n\n    # The query should NOT include metadata in the update\n    # because metadata was None and should be excluded from the data dict\n    assert \"metadata\" not in query.lower()\n    assert \"metadata\" not in str(params.values())\n\n\n@pytest.mark.asyncio\nasync def test_update_thread_merges_metadata_when_provided():\n    \"\"\"Test that update_thread merges metadata correctly when provided.\"\"\"\n    # Create a mock data layer\n    data_layer = ChainlitDataLayer(\n        database_url=\"postgresql://test\", storage_client=None, show_logger=False\n    )\n\n    # Mock the execute_query method to return existing metadata\n    existing_metadata = {\"is_shared\": True, \"custom_field\": \"original\"}\n\n    async def mock_execute_query(query, params):\n        if \"SELECT\" in query and \"metadata\" in query:\n            # Return existing thread metadata\n            return [{\"metadata\": json.dumps(existing_metadata)}]\n        # For the UPDATE/INSERT, return None\n        return None\n\n    data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)\n\n    # Call update_thread with partial metadata update\n    new_metadata = {\"custom_field\": \"updated\", \"new_field\": \"added\"}\n    await data_layer.update_thread(\n        thread_id=\"test-thread-123\", name=\"Updated Name\", metadata=new_metadata\n    )\n\n    # Verify execute_query was called twice (once for SELECT, once for UPDATE)\n    assert data_layer.execute_query.call_count == 2\n\n    # Get the UPDATE call\n    update_call = data_layer.execute_query.call_args_list[1]\n    update_params = update_call[0][1]\n\n    # The metadata should be merged\n    # Expected: {\"is_shared\": True, \"custom_field\": \"updated\", \"new_field\": \"added\"}\n    # Find the JSON metadata in the params\n    metadata_json = None\n    for value in update_params.values():\n        if isinstance(value, str) and value.startswith(\"{\"):\n            try:\n                metadata_json = json.loads(value)\n                break\n            except json.JSONDecodeError:\n                pass\n\n    assert metadata_json is not None\n    assert metadata_json.get(\"is_shared\") is True\n    assert metadata_json.get(\"custom_field\") == \"updated\"\n    assert metadata_json.get(\"new_field\") == \"added\"\n\n\n@pytest.mark.asyncio\nasync def test_update_thread_deletes_keys_with_none_values():\n    \"\"\"Test that update_thread deletes keys when value is None.\"\"\"\n    # Create a mock data layer\n    data_layer = ChainlitDataLayer(\n        database_url=\"postgresql://test\", storage_client=None, show_logger=False\n    )\n\n    # Mock the execute_query method to return existing metadata\n    existing_metadata = {\n        \"is_shared\": True,\n        \"to_delete\": \"will be removed\",\n        \"keep\": \"stays\",\n    }\n\n    async def mock_execute_query(query, params):\n        if \"SELECT\" in query and \"metadata\" in query:\n            # Return existing thread metadata\n            return [{\"metadata\": json.dumps(existing_metadata)}]\n        # For the UPDATE/INSERT, return None\n        return None\n\n    data_layer.execute_query = AsyncMock(side_effect=mock_execute_query)\n\n    # Call update_thread with None value to delete a key\n    new_metadata = {\"to_delete\": None, \"new_field\": \"added\"}\n    await data_layer.update_thread(thread_id=\"test-thread-123\", metadata=new_metadata)\n\n    # Verify execute_query was called twice\n    assert data_layer.execute_query.call_count == 2\n\n    # Get the UPDATE call\n    update_call = data_layer.execute_query.call_args_list[1]\n    update_params = update_call[0][1]\n\n    # The metadata should have deleted \"to_delete\" key and added \"new_field\"\n    # Expected: {\"is_shared\": True, \"keep\": \"stays\", \"new_field\": \"added\"}\n    metadata_json = None\n    for value in update_params.values():\n        if isinstance(value, str) and value.startswith(\"{\"):\n            try:\n                metadata_json = json.loads(value)\n                break\n            except json.JSONDecodeError:\n                pass\n\n    if metadata_json:\n        # Verify \"to_delete\" is not in the merged metadata\n        assert \"to_delete\" not in metadata_json\n        # Verify \"new_field\" was added\n        assert metadata_json.get(\"new_field\") == \"added\"\n        # Verify \"is_shared\" and \"keep\" are preserved\n        assert metadata_json.get(\"is_shared\") is True\n        assert metadata_json.get(\"keep\") == \"stays\"\n\n\n@pytest.mark.asyncio\nasync def test_create_step_uses_nullif_for_output_and_input():\n    \"\"\"Empty-string output/input should not overwrite existing content.\n\n    Regression test for https://github.com/Chainlit/chainlit/issues/2789\n    The SQL uses NULLIF(EXCLUDED.output, '') so that an empty string from the\n    initial Step.send() is treated as NULL by COALESCE, preventing it from\n    overwriting non-empty content saved by a subsequent Step.update().\n    \"\"\"\n    import inspect\n\n    source = inspect.getsource(ChainlitDataLayer.create_step)\n\n    assert \"NULLIF(EXCLUDED.output, '')\" in source, (\n        \"output should use NULLIF to treat empty string as NULL\"\n    )\n    assert \"NULLIF(EXCLUDED.input, '')\" in source, (\n        \"input should use NULLIF to treat empty string as NULL\"\n    )\n"
  },
  {
    "path": "backend/tests/data/test_get_data_layer.py",
    "content": "from unittest.mock import AsyncMock, Mock\n\nfrom chainlit.data import get_data_layer\n\n\nasync def test_get_data_layer(\n    mock_data_layer: AsyncMock,\n    mock_get_data_layer: Mock,\n):\n    # Check whether the data layer is properly set\n    assert mock_data_layer == get_data_layer()\n\n    mock_get_data_layer.assert_called_once()\n\n    # Getting the data layer again, should not result in additional call\n    assert mock_data_layer == get_data_layer()\n\n    mock_get_data_layer.assert_called_once()\n"
  },
  {
    "path": "backend/tests/data/test_literalai.py",
    "content": "import datetime\nimport uuid\nfrom unittest.mock import ANY, AsyncMock, Mock, patch\n\nimport pytest\nfrom httpx import HTTPStatusError, RequestError\nfrom literalai import (\n    AsyncLiteralClient,\n    Attachment,\n    Attachment as LiteralAttachment,\n    PageInfo,\n    PaginatedResponse,\n    Score as LiteralScore,\n    Step as LiteralStep,\n    Thread,\n    Thread as LiteralThread,\n    User as LiteralUser,\n    UserDict,\n)\nfrom literalai.api import AsyncLiteralAPI\nfrom literalai.observability.step import (\n    AttachmentDict as LiteralAttachmentDict,\n    StepDict as LiteralStepDict,\n)\nfrom literalai.observability.thread import ThreadDict as LiteralThreadDict\n\nfrom chainlit.data.literalai import LiteralDataLayer, LiteralToChainlitConverter\nfrom chainlit.element import Audio, File, Image, Pdf, Text, Video\nfrom chainlit.step import Step, StepDict\nfrom chainlit.types import (\n    Feedback,\n    Pagination,\n    ThreadFilter,\n)\nfrom chainlit.user import PersistedUser, User\n\n\n@pytest.fixture\nasync def mock_literal_client(monkeypatch: pytest.MonkeyPatch):\n    client = Mock(spec=AsyncLiteralClient)\n    client.api = AsyncMock(spec=AsyncLiteralAPI)\n    monkeypatch.setattr(\"literalai.AsyncLiteralClient\", client)\n    return client\n\n\n@pytest.fixture\nasync def literal_data_layer(mock_literal_client):\n    data_layer = LiteralDataLayer(api_key=\"fake_api_key\", server=\"https://fake.server\")\n    data_layer.client = mock_literal_client\n    return data_layer\n\n\n@pytest.fixture\ndef test_thread():\n    return LiteralThread.from_dict(\n        {\n            \"id\": \"test_thread_id\",\n            \"name\": \"Test Thread\",\n            \"createdAt\": \"2023-01-01T00:00:00Z\",\n            \"metadata\": {},\n            \"participant\": {},\n            \"steps\": [],\n            \"tags\": [],\n        }\n    )\n\n\n@pytest.fixture\ndef test_step_dict(test_thread) -> StepDict:\n    return {\n        \"createdAt\": \"2023-01-01T00:00:00Z\",\n        \"start\": \"2023-01-01T00:00:00Z\",\n        \"end\": \"2023-01-01T00:00:00Z\",\n        \"generation\": {},\n        \"id\": \"test_step_id\",\n        \"name\": \"Test Step\",\n        \"threadId\": test_thread.id,\n        \"type\": \"user_message\",\n        \"tags\": [],\n        \"metadata\": {\"key\": \"value\"},\n        \"input\": \"test input\",\n        \"output\": \"test output\",\n        \"waitForAnswer\": True,\n        \"showInput\": True,\n        \"language\": \"en\",\n    }\n\n\n@pytest.fixture\ndef test_step(test_thread: LiteralThread):\n    return LiteralStep.from_dict(\n        {\n            \"id\": str(uuid.uuid4()),\n            \"name\": \"Test Step\",\n            \"type\": \"user_message\",\n            \"environment\": None,\n            \"threadId\": test_thread.id,\n            \"error\": None,\n            \"input\": {},\n            \"output\": {},\n            \"metadata\": {},\n            \"tags\": [],\n            \"parentId\": None,\n            \"createdAt\": \"2023-01-01T00:00:00Z\",\n            \"startTime\": \"2023-01-01T00:00:00Z\",\n            \"endTime\": \"2023-01-01T00:00:00Z\",\n            \"generation\": {},\n            \"scores\": [],\n            \"attachments\": [],\n            \"rootRunId\": None,\n        }\n    )\n\n\n@pytest.fixture\ndef literal_test_user(test_user: User):\n    return LiteralUser(\n        id=str(uuid.uuid4()),\n        created_at=datetime.datetime.now().isoformat(),\n        identifier=test_user.identifier,\n        metadata=test_user.metadata,\n    )\n\n\n@pytest.fixture\ndef test_filters() -> ThreadFilter:\n    return ThreadFilter(feedback=1, userId=\"user1\", search=\"test\")\n\n\n@pytest.fixture\ndef test_pagination() -> Pagination:\n    return Pagination(first=10, cursor=None)\n\n\n@pytest.fixture\ndef test_attachment(\n    test_thread: LiteralThread, test_step: LiteralStep\n) -> LiteralAttachment:\n    return Attachment(\n        id=\"test_attachment_id\",\n        step_id=test_step.id,\n        thread_id=test_thread.id,\n        metadata={\n            \"display\": \"side\",\n            \"language\": \"python\",\n            \"type\": \"file\",\n        },\n        mime=\"text/plain\",\n        name=\"test_file.txt\",\n        object_key=\"test_object_key\",\n        url=\"https://example.com/test_file.txt\",\n    )\n\n\nasync def test_create_step(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_step_dict: StepDict,\n    mock_chainlit_context,\n):\n    async with mock_chainlit_context:\n        await literal_data_layer.create_step(test_step_dict)\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(\n        [\n            {\n                \"createdAt\": \"2023-01-01T00:00:00Z\",\n                \"startTime\": \"2023-01-01T00:00:00Z\",\n                \"endTime\": \"2023-01-01T00:00:00Z\",\n                \"generation\": {},\n                \"id\": \"test_step_id\",\n                \"parentId\": None,\n                \"name\": \"Test Step\",\n                \"threadId\": \"test_thread_id\",\n                \"type\": \"user_message\",\n                \"tags\": [],\n                \"metadata\": {\n                    \"key\": \"value\",\n                    \"waitForAnswer\": True,\n                    \"language\": \"en\",\n                    \"showInput\": True,\n                },\n                \"input\": {\"content\": \"test input\"},\n                \"output\": {\"content\": \"test output\"},\n            }\n        ]\n    )\n\n\nasync def test_safely_send_steps_success(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n):\n    test_steps = [{\"id\": \"test_step_id\", \"name\": \"Test Step\"}]\n\n    async with mock_chainlit_context:\n        await literal_data_layer.safely_send_steps(test_steps)\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps)\n\n\nasync def test_safely_send_steps_http_status_error(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n    caplog,\n):\n    test_steps = [{\"id\": \"test_step_id\", \"name\": \"Test Step\"}]\n    mock_literal_client.api.send_steps.side_effect = HTTPStatusError(\n        \"HTTP Error\", request=Mock(), response=Mock(status_code=500)\n    )\n\n    async with mock_chainlit_context:\n        await literal_data_layer.safely_send_steps(test_steps)\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps)\n    assert \"HTTP Request: error sending steps: 500\" in caplog.text\n\n\nasync def test_safely_send_steps_request_error(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n    caplog,\n):\n    test_steps = [{\"id\": \"test_step_id\", \"name\": \"Test Step\"}]\n    mock_request = Mock()\n    mock_request.url = \"https://example.com/api\"\n    mock_literal_client.api.send_steps.side_effect = RequestError(\n        \"Request Error\", request=mock_request\n    )\n\n    async with mock_chainlit_context:\n        await literal_data_layer.safely_send_steps(test_steps)\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps)\n    assert \"HTTP Request: error for 'https://example.com/api'.\" in caplog.text\n\n\nasync def test_get_user(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    literal_test_user: LiteralUser,\n    persisted_test_user: PersistedUser,\n):\n    mock_literal_client.api.get_user.return_value = literal_test_user\n\n    user = await literal_data_layer.get_user(\"test_user_id\")\n\n    assert user is not None\n    assert user.id == literal_test_user.id\n    assert user.identifier == literal_test_user.identifier\n\n    mock_literal_client.api.get_user.assert_awaited_once_with(identifier=\"test_user_id\")\n\n\nasync def test_get_user_not_found(\n    literal_data_layer: LiteralDataLayer, mock_literal_client: Mock\n):\n    mock_literal_client.api.get_user.return_value = None\n\n    user = await literal_data_layer.get_user(\"non_existent_user_id\")\n\n    assert user is None\n    mock_literal_client.api.get_user.assert_awaited_once_with(\n        identifier=\"non_existent_user_id\"\n    )\n\n\nasync def test_create_user_not_existing(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_user: User,\n    literal_test_user: LiteralUser,\n):\n    mock_literal_client.api.get_user.return_value = None\n    mock_literal_client.api.create_user.return_value = literal_test_user\n\n    persisted_user = await literal_data_layer.create_user(test_user)\n\n    mock_literal_client.api.create_user.assert_awaited_once_with(\n        identifier=test_user.identifier, metadata=test_user.metadata\n    )\n\n    assert persisted_user is not None\n    assert isinstance(persisted_user, PersistedUser)\n    assert persisted_user.id == literal_test_user.id\n    assert persisted_user.identifier == literal_test_user.identifier\n\n\nasync def test_create_user_update_existing(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_user: User,\n    literal_test_user: LiteralUser,\n    persisted_test_user: PersistedUser,\n):\n    mock_literal_client.api.get_user.return_value = literal_test_user\n\n    persisted_user = await literal_data_layer.create_user(test_user)\n\n    mock_literal_client.api.create_user.assert_not_called()\n    mock_literal_client.api.update_user.assert_awaited_once_with(\n        id=literal_test_user.id, metadata=test_user.metadata\n    )\n\n    assert persisted_user is not None\n    assert isinstance(persisted_user, PersistedUser)\n    assert persisted_user.id == literal_test_user.id\n    assert persisted_user.identifier == literal_test_user.identifier\n\n\nasync def test_create_user_id_none(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_user: User,\n    literal_test_user: LiteralUser,\n):\n    \"\"\"Weird edge case; persisted user without an id. Do we need this!??\"\"\"\n\n    literal_test_user.id = None\n    mock_literal_client.api.get_user.return_value = literal_test_user\n\n    persisted_user = await literal_data_layer.create_user(test_user)\n\n    mock_literal_client.api.create_user.assert_not_called()\n    mock_literal_client.api.update_user.assert_not_called()\n\n    assert persisted_user is not None\n    assert isinstance(persisted_user, PersistedUser)\n    assert persisted_user.id == \"\"\n    assert persisted_user.identifier == literal_test_user.identifier\n\n\nasync def test_update_thread(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_thread: LiteralThread,\n):\n    await literal_data_layer.update_thread(test_thread.id, name=test_thread.name)\n\n    mock_literal_client.api.upsert_thread.assert_awaited_once_with(\n        id=test_thread.id,\n        name=test_thread.name,\n        participant_id=None,\n        metadata=None,\n        tags=None,\n    )\n\n\nasync def test_get_thread_author(\n    literal_data_layer, mock_literal_client: Mock, test_thread: LiteralThread\n):\n    test_thread.participant_identifier = \"test_user_identifier\"\n    mock_literal_client.api.get_thread.return_value = test_thread\n\n    author = await literal_data_layer.get_thread_author(test_thread.id)\n\n    assert author == \"test_user_identifier\"\n    mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id)\n\n\nasync def test_get_thread(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_thread: LiteralThread,\n    test_step: LiteralStep,\n):\n    assert isinstance(test_thread.steps, list)\n    test_thread.steps.append(test_step)\n\n    mock_literal_client.api.get_thread.return_value = test_thread\n\n    thread = await literal_data_layer.get_thread(test_thread.id)\n    mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id)\n\n    assert thread is not None\n    assert thread[\"id\"] == test_thread.id\n    assert thread[\"name\"] == test_thread.name\n    assert len(thread[\"steps\"]) == 1\n    assert thread[\"steps\"][0].get(\"id\") == test_step.id\n\n\nasync def test_get_thread_with_stub_step(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_thread: LiteralThread,\n):\n    # Create a step that should be stubbed\n    stub_step = LiteralStep.from_dict(\n        {\n            \"id\": \"stub_step_id\",\n            \"name\": \"Stub Step\",\n            \"type\": \"undefined\",\n            \"threadId\": test_thread.id,\n            \"createdAt\": \"2023-01-01T00:00:00Z\",\n        }\n    )\n    test_thread.steps = [stub_step]\n\n    mock_literal_client.api.get_thread.return_value = test_thread\n\n    # Mock the config.ui.cot value to ensure check_add_step_in_cot returns False\n    with patch(\"chainlit.config.config.ui.cot\", \"hidden\"):\n        thread = await literal_data_layer.get_thread(test_thread.id)\n\n    mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id)\n\n    assert thread is not None\n    assert thread[\"id\"] == test_thread.id\n    assert thread[\"name\"] == test_thread.name\n    assert len(thread[\"steps\"]) == 1\n    assert thread[\"steps\"][0].get(\"id\") == \"stub_step_id\"\n    assert thread[\"steps\"][0].get(\"type\") == \"undefined\"\n    assert thread[\"steps\"][0].get(\"input\") == \"\"\n    assert thread[\"steps\"][0].get(\"output\") == \"\"\n\n    # Additional assertions to ensure the step is stubbed\n    assert \"metadata\" not in thread[\"steps\"][0]\n    assert \"createdAt\" not in thread[\"steps\"][0]\n\n\nasync def test_get_thread_with_attachment(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_thread: LiteralThread,\n    test_step: LiteralStep,\n    test_attachment: LiteralAttachment,\n):\n    # Add the attachment to the test step\n    test_step.attachments = [test_attachment]\n    test_thread.steps = [test_step]\n\n    mock_literal_client.api.get_thread.return_value = test_thread\n\n    thread = await literal_data_layer.get_thread(test_thread.id)\n    mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id)\n\n    assert thread is not None\n    assert thread[\"id\"] == test_thread.id\n    assert thread[\"name\"] == test_thread.name\n    assert thread[\"steps\"] is not None\n    assert len(thread[\"steps\"]) == 1\n    assert thread[\"elements\"] is not None\n    assert len(thread[\"elements\"]) == 1\n\n    element = thread[\"elements\"][0] if thread[\"elements\"] else None\n    assert element is not None\n    assert element[\"id\"] == \"test_attachment_id\"\n    assert element[\"forId\"] == test_step.id\n    assert element[\"threadId\"] == test_thread.id\n    assert element[\"type\"] == \"file\"\n    assert element[\"display\"] == \"side\"\n    assert element[\"language\"] == \"python\"\n    assert element[\"mime\"] == \"text/plain\"\n    assert element[\"name\"] == \"test_file.txt\"\n    assert element[\"objectKey\"] == \"test_object_key\"\n    assert element[\"url\"] == \"https://example.com/test_file.txt\"\n\n\nasync def test_get_thread_non_existing(\n    literal_data_layer: LiteralDataLayer, mock_literal_client: Mock\n):\n    mock_literal_client.api.get_thread.return_value = None\n\n    thread = await literal_data_layer.get_thread(\"non_existent_thread_id\")\n    mock_literal_client.api.get_thread.assert_awaited_once_with(\n        id=\"non_existent_thread_id\"\n    )\n\n    assert thread is None\n\n\nasync def test_delete_thread(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_thread: LiteralThread,\n):\n    await literal_data_layer.delete_thread(test_thread.id)\n\n    mock_literal_client.api.delete_thread.assert_awaited_once_with(id=test_thread.id)\n\n\nasync def test_list_threads(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_filters: ThreadFilter,\n    test_pagination: Pagination,\n):\n    response: PaginatedResponse[Thread] = PaginatedResponse(\n        page_info=PageInfo(\n            has_next_page=True, start_cursor=\"start_cursor\", end_cursor=\"end_cursor\"\n        ),\n        data=[\n            Thread(\n                id=\"thread1\",\n                name=\"Thread 1\",\n            ),\n            Thread(\n                id=\"thread2\",\n                name=\"Thread 2\",\n            ),\n        ],\n    )\n\n    mock_literal_client.api.list_threads.return_value = response\n\n    result = await literal_data_layer.list_threads(test_pagination, test_filters)\n\n    mock_literal_client.api.list_threads.assert_awaited_once_with(\n        first=10,\n        after=None,\n        filters=[\n            {\"field\": \"participantId\", \"operator\": \"eq\", \"value\": \"user1\"},\n            {\n                \"field\": \"stepOutput\",\n                \"operator\": \"ilike\",\n                \"value\": \"test\",\n                \"path\": \"content\",\n            },\n            {\n                \"field\": \"scoreValue\",\n                \"operator\": \"eq\",\n                \"value\": 1,\n                \"path\": \"user-feedback\",\n            },\n        ],\n        order_by={\"column\": \"createdAt\", \"direction\": \"DESC\"},\n    )\n\n    assert result.pageInfo.hasNextPage\n    assert result.pageInfo.startCursor == \"start_cursor\"\n    assert result.pageInfo.endCursor == \"end_cursor\"\n    assert len(result.data) == 2\n    assert result.data[0][\"id\"] == \"thread1\"\n    assert result.data[1][\"id\"] == \"thread2\"\n\n\nasync def test_create_element(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n):\n    mock_literal_client.api.upload_file.return_value = {\"object_key\": \"test_object_key\"}\n\n    async with mock_chainlit_context:\n        text_element = Text(\n            id=str(uuid.uuid4()),\n            name=\"test.txt\",\n            mime=\"text/plain\",\n            content=\"test content\",\n            for_id=\"test_step_id\",\n        )\n\n        await literal_data_layer.create_element(text_element)\n\n    mock_literal_client.api.upload_file.assert_awaited_once_with(\n        content=text_element.content,\n        mime=text_element.mime,\n        thread_id=text_element.thread_id,\n    )\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(\n        [\n            {\n                \"id\": text_element.for_id,\n                \"threadId\": text_element.thread_id,\n                \"attachments\": [\n                    {\n                        \"id\": ANY,\n                        \"name\": text_element.name,\n                        \"metadata\": {\n                            \"size\": None,\n                            \"language\": None,\n                            \"display\": text_element.display,\n                            \"type\": text_element.type,\n                            \"page\": None,\n                            \"props\": None,\n                        },\n                        \"mime\": text_element.mime,\n                        \"url\": None,\n                        \"objectKey\": \"test_object_key\",\n                    }\n                ],\n            }\n        ]\n    )\n\n\nasync def test_get_element(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    test_attachment: LiteralAttachment,\n):\n    mock_literal_client.api.get_attachment.return_value = test_attachment\n\n    element_dict = await literal_data_layer.get_element(\n        \"test_thread_id\", \"test_element_id\"\n    )\n\n    mock_literal_client.api.get_attachment.assert_awaited_once_with(\n        id=\"test_element_id\"\n    )\n\n    assert element_dict is not None\n\n    # Compare element_dict attributes to attachment attributes\n    assert element_dict[\"id\"] == test_attachment.id\n    assert element_dict[\"forId\"] == test_attachment.step_id\n    assert element_dict[\"threadId\"] == test_attachment.thread_id\n    assert element_dict[\"name\"] == test_attachment.name\n    assert element_dict[\"mime\"] == test_attachment.mime\n    assert element_dict[\"url\"] == test_attachment.url\n    assert element_dict[\"objectKey\"] == test_attachment.object_key\n    assert test_attachment.metadata\n    assert element_dict[\"display\"] == test_attachment.metadata[\"display\"]\n    assert element_dict[\"language\"] == test_attachment.metadata[\"language\"]\n    assert element_dict[\"type\"] == test_attachment.metadata[\"type\"]\n\n\nasync def test_upsert_feedback_create(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n):\n    feedback = Feedback(forId=\"test_step_id\", value=1, comment=\"Great!\")\n    mock_literal_client.api.create_score.return_value = Mock(id=\"new_feedback_id\")\n\n    result = await literal_data_layer.upsert_feedback(feedback)\n\n    mock_literal_client.api.create_score.assert_awaited_once_with(\n        step_id=\"test_step_id\",\n        value=1,\n        comment=\"Great!\",\n        name=\"user-feedback\",\n        type=\"HUMAN\",\n    )\n    assert result == \"new_feedback_id\"\n\n\nasync def test_upsert_feedback_update(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n):\n    feedback = Feedback(\n        id=\"existing_feedback_id\",\n        forId=\"test_step_id\",\n        value=0,\n        comment=\"Needs improvement\",\n    )\n\n    result = await literal_data_layer.upsert_feedback(feedback)\n\n    mock_literal_client.api.update_score.assert_awaited_once_with(\n        id=\"existing_feedback_id\",\n        update_params={\n            \"comment\": \"Needs improvement\",\n            \"value\": 0,\n        },\n    )\n    assert result == \"existing_feedback_id\"\n\n\nasync def test_delete_feedback(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n):\n    feedback_id = \"test_feedback_id\"\n\n    result = await literal_data_layer.delete_feedback(feedback_id)\n\n    mock_literal_client.api.delete_score.assert_awaited_once_with(id=feedback_id)\n    assert result is True\n\n\nasync def test_delete_feedback_empty_id(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n):\n    feedback_id = \"\"\n\n    result = await literal_data_layer.delete_feedback(feedback_id)\n\n    mock_literal_client.api.delete_score.assert_not_awaited()\n    assert result is False\n\n\nasync def test_build_debug_url(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n):\n    mock_literal_client.api.get_my_project_id.return_value = \"test_project_id\"\n    mock_literal_client.api.url = \"https://api.example.com\"\n\n    result = await literal_data_layer.build_debug_url()\n\n    mock_literal_client.api.get_my_project_id.assert_awaited_once()\n    assert (\n        result\n        == \"https://api.example.com/projects/test_project_id/logs/threads/[thread_id]?currentStepId=[step_id]\"\n    )\n\n\nasync def test_build_debug_url_error(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    caplog,\n):\n    mock_literal_client.api.get_my_project_id.side_effect = Exception(\"API Error\")\n\n    result = await literal_data_layer.build_debug_url()\n\n    mock_literal_client.api.get_my_project_id.assert_awaited_once()\n    assert result == \"\"\n    assert \"Error building debug url: API Error\" in caplog.text\n\n\nasync def test_delete_element(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n):\n    element_id = \"test_element_id\"\n\n    async with mock_chainlit_context:\n        await literal_data_layer.delete_element(element_id)\n\n    mock_literal_client.api.delete_attachment.assert_awaited_once_with(id=element_id)\n\n\nasync def test_delete_step(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n):\n    step_id = \"test_step_id\"\n\n    async with mock_chainlit_context:\n        await literal_data_layer.delete_step(step_id)\n\n    mock_literal_client.api.delete_step.assert_awaited_once_with(id=step_id)\n\n\nasync def test_update_step(\n    literal_data_layer: LiteralDataLayer,\n    mock_literal_client: Mock,\n    mock_chainlit_context,\n    test_step_dict: StepDict,\n):\n    async with mock_chainlit_context:\n        await literal_data_layer.update_step(test_step_dict)\n\n    mock_literal_client.api.send_steps.assert_awaited_once_with(\n        [\n            {\n                \"createdAt\": \"2023-01-01T00:00:00Z\",\n                \"startTime\": \"2023-01-01T00:00:00Z\",\n                \"endTime\": \"2023-01-01T00:00:00Z\",\n                \"generation\": {},\n                \"id\": \"test_step_id\",\n                \"parentId\": None,\n                \"name\": \"Test Step\",\n                \"threadId\": \"test_thread_id\",\n                \"type\": \"user_message\",\n                \"tags\": [],\n                \"metadata\": {\n                    \"key\": \"value\",\n                    \"waitForAnswer\": True,\n                    \"language\": \"en\",\n                    \"showInput\": True,\n                },\n                \"input\": {\"content\": \"test input\"},\n                \"output\": {\"content\": \"test output\"},\n            }\n        ]\n    )\n\n\ndef test_steptype_to_steptype():\n    assert (\n        LiteralToChainlitConverter.steptype_to_steptype(\"user_message\")\n        == \"user_message\"\n    )\n    assert (\n        LiteralToChainlitConverter.steptype_to_steptype(\"assistant_message\")\n        == \"assistant_message\"\n    )\n    assert (\n        LiteralToChainlitConverter.steptype_to_steptype(\"system_message\")\n        == \"system_message\"\n    )\n    assert LiteralToChainlitConverter.steptype_to_steptype(\"tool\") == \"tool\"\n    assert LiteralToChainlitConverter.steptype_to_steptype(None) == \"undefined\"\n\n\ndef test_score_to_feedbackdict():\n    score = LiteralScore(\n        id=\"test_score_id\",\n        step_id=\"test_step_id\",\n        value=1,\n        comment=\"Great job!\",\n        name=\"user-feedback\",\n        type=\"HUMAN\",\n        dataset_experiment_item_id=None,\n        tags=None,\n    )\n    feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score)\n    assert feedback_dict == {\n        \"id\": \"test_score_id\",\n        \"forId\": \"test_step_id\",\n        \"value\": 1,\n        \"comment\": \"Great job!\",\n    }\n\n    assert LiteralToChainlitConverter.score_to_feedbackdict(None) is None\n\n    score.value = 0\n    feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score)\n    assert feedback_dict is not None\n    assert feedback_dict[\"value\"] == 0\n\n    score.id = None\n    score.step_id = None\n    feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score)\n    assert feedback_dict is not None\n    assert feedback_dict[\"id\"] == \"\"\n    assert feedback_dict[\"forId\"] == \"\"\n\n\ndef test_step_to_stepdict():\n    literal_step = LiteralStep.from_dict(\n        {\n            \"id\": \"test_step_id\",\n            \"threadId\": \"test_thread_id\",\n            \"type\": \"user_message\",\n            \"name\": \"Test Step\",\n            \"input\": {\"content\": \"test input\"},\n            \"output\": {\"content\": \"test output\"},\n            \"startTime\": \"2023-01-01T00:00:00Z\",\n            \"endTime\": \"2023-01-01T00:00:01Z\",\n            \"createdAt\": \"2023-01-01T00:00:00Z\",\n            \"metadata\": {\"showInput\": True, \"language\": \"en\"},\n            \"error\": None,\n            \"scores\": [\n                {\n                    \"id\": \"test_score_id\",\n                    \"stepId\": \"test_step_id\",\n                    \"value\": 1,\n                    \"comment\": \"Great job!\",\n                    \"name\": \"user-feedback\",\n                    \"type\": \"HUMAN\",\n                }\n            ],\n        }\n    )\n\n    step_dict = LiteralToChainlitConverter.step_to_stepdict(literal_step)\n\n    assert step_dict.get(\"id\") == \"test_step_id\"\n    assert step_dict.get(\"threadId\") == \"test_thread_id\"\n    assert step_dict.get(\"type\") == \"user_message\"\n    assert step_dict.get(\"name\") == \"Test Step\"\n    assert step_dict.get(\"input\") == \"test input\"\n    assert step_dict.get(\"output\") == \"test output\"\n    assert step_dict.get(\"start\") == \"2023-01-01T00:00:00Z\"\n    assert step_dict.get(\"end\") == \"2023-01-01T00:00:01Z\"\n    assert step_dict.get(\"createdAt\") == \"2023-01-01T00:00:00Z\"\n    assert step_dict.get(\"showInput\") == True\n    assert step_dict.get(\"language\") == \"en\"\n    assert step_dict.get(\"isError\") == False\n    assert step_dict.get(\"feedback\") == {\n        \"id\": \"test_score_id\",\n        \"forId\": \"test_step_id\",\n        \"value\": 1,\n        \"comment\": \"Great job!\",\n    }\n\n\ndef test_attachment_to_elementdict():\n    attachment = Attachment(\n        id=\"test_attachment_id\",\n        step_id=\"test_step_id\",\n        thread_id=\"test_thread_id\",\n        name=\"test.txt\",\n        mime=\"text/plain\",\n        url=\"https://example.com/test.txt\",\n        object_key=\"test_object_key\",\n        metadata={\n            \"display\": \"side\",\n            \"language\": \"python\",\n            \"type\": \"file\",\n            \"size\": \"large\",\n        },\n    )\n\n    element_dict = LiteralToChainlitConverter.attachment_to_elementdict(attachment)\n\n    assert element_dict[\"id\"] == \"test_attachment_id\"\n    assert element_dict[\"forId\"] == \"test_step_id\"\n    assert element_dict[\"threadId\"] == \"test_thread_id\"\n    assert element_dict[\"name\"] == \"test.txt\"\n    assert element_dict[\"mime\"] == \"text/plain\"\n    assert element_dict[\"url\"] == \"https://example.com/test.txt\"\n    assert element_dict[\"objectKey\"] == \"test_object_key\"\n    assert element_dict[\"display\"] == \"side\"\n    assert element_dict[\"language\"] == \"python\"\n    assert element_dict[\"type\"] == \"file\"\n    assert element_dict[\"size\"] == \"large\"\n\n\ndef test_attachment_to_element():\n    attachment = Attachment(\n        id=\"test_attachment_id\",\n        step_id=\"test_step_id\",\n        thread_id=\"test_thread_id\",\n        name=\"test.txt\",\n        mime=\"text/plain\",\n        url=\"https://example.com/test.txt\",\n        object_key=\"test_object_key\",\n        metadata={\n            \"display\": \"side\",\n            \"language\": \"python\",\n            \"type\": \"text\",\n            \"size\": \"small\",\n        },\n    )\n\n    element = LiteralToChainlitConverter.attachment_to_element(attachment)\n\n    assert isinstance(element, Text)\n    assert element.id == \"test_attachment_id\"\n    assert element.for_id == \"test_step_id\"\n    assert element.thread_id == \"test_thread_id\"\n    assert element.name == \"test.txt\"\n    assert element.mime == \"text/plain\"\n    assert element.url == \"https://example.com/test.txt\"\n    assert element.object_key == \"test_object_key\"\n    assert element.display == \"side\"\n    assert element.language == \"python\"\n    assert element.size == \"small\"\n\n    # Test other element types\n    for element_type in [\"file\", \"image\", \"audio\", \"video\", \"pdf\"]:\n        attachment.metadata = {\"type\": element_type, \"size\": \"small\"}\n\n        element = LiteralToChainlitConverter.attachment_to_element(attachment)\n        assert isinstance(\n            element,\n            {\n                \"file\": File,\n                \"image\": Image,\n                \"audio\": Audio,\n                \"video\": Video,\n                \"text\": Text,\n                \"pdf\": Pdf,\n            }[element_type],\n        )\n\n\ndef test_step_to_step():\n    literal_step = LiteralStep.from_dict(\n        {\n            \"id\": \"test_step_id\",\n            \"threadId\": \"test_thread_id\",\n            \"type\": \"user_message\",\n            \"name\": \"Test Step\",\n            \"input\": {\"content\": \"test input\"},\n            \"output\": {\"content\": \"test output\"},\n            \"startTime\": \"2023-01-01T00:00:00Z\",\n            \"endTime\": \"2023-01-01T00:00:01Z\",\n            \"createdAt\": \"2023-01-01T00:00:00Z\",\n            \"metadata\": {\"showInput\": True, \"language\": \"en\"},\n            \"error\": None,\n            \"attachments\": [\n                {\n                    \"id\": \"test_attachment_id\",\n                    \"name\": \"test.txt\",\n                    \"mime\": \"text/plain\",\n                    \"url\": \"https://example.com/test.txt\",\n                    \"objectKey\": \"test_object_key\",\n                    \"metadata\": {\n                        \"display\": \"side\",\n                        \"language\": \"python\",\n                        \"type\": \"text\",\n                    },\n                }\n            ],\n        }\n    )\n\n    chainlit_step = LiteralToChainlitConverter.step_to_step(literal_step)\n\n    assert isinstance(chainlit_step, Step)\n    assert chainlit_step.id == \"test_step_id\"\n    assert chainlit_step.thread_id == \"test_thread_id\"\n    assert chainlit_step.type == \"user_message\"\n    assert chainlit_step.name == \"Test Step\"\n    assert chainlit_step.input == \"test input\"\n    assert chainlit_step.output == \"test output\"\n    assert chainlit_step.start == \"2023-01-01T00:00:00Z\"\n    assert chainlit_step.end == \"2023-01-01T00:00:01Z\"\n    assert chainlit_step.created_at == \"2023-01-01T00:00:00Z\"\n    assert chainlit_step.metadata == {\"showInput\": True, \"language\": \"en\"}\n    assert not chainlit_step.is_error\n    assert chainlit_step.elements is not None\n    assert len(chainlit_step.elements) == 1\n    assert isinstance(chainlit_step.elements[0], Text)\n\n\ndef test_thread_to_threaddict():\n    attachment_dict = LiteralAttachmentDict(\n        id=\"test_attachment_id\",\n        stepId=\"test_step_id\",\n        threadId=\"test_thread_id\",\n        name=\"test.txt\",\n        mime=\"text/plain\",\n        url=\"https://example.com/test.txt\",\n        objectKey=\"test_object_key\",\n        metadata={\n            \"display\": \"side\",\n            \"language\": \"python\",\n            \"type\": \"text\",\n        },\n    )\n    step_dict = LiteralStepDict(\n        id=\"test_step_id\",\n        threadId=\"test_thread_id\",\n        type=\"user_message\",\n        name=\"Test Step\",\n        input={\"content\": \"test input\"},\n        output={\"content\": \"test output\"},\n        startTime=\"2023-01-01T00:00:00Z\",\n        endTime=\"2023-01-01T00:00:01Z\",\n        createdAt=\"2023-01-01T00:00:00Z\",\n        metadata={\"showInput\": True, \"language\": \"en\"},\n        error=None,\n        attachments=[attachment_dict],\n    )\n    literal_thread = LiteralThread.from_dict(\n        LiteralThreadDict(\n            id=\"test_thread_id\",\n            name=\"Test Thread\",\n            createdAt=\"2023-01-01T00:00:00Z\",\n            participant=UserDict(id=\"test_user_id\", identifier=\"test_user_identifier_\"),\n            tags=[\"tag1\", \"tag2\"],\n            metadata={\"key\": \"value\"},\n            steps=[step_dict],\n        )\n    )\n\n    thread_dict = LiteralToChainlitConverter.thread_to_threaddict(literal_thread)\n\n    assert thread_dict[\"id\"] == \"test_thread_id\"\n    assert thread_dict[\"name\"] == \"Test Thread\"\n    assert thread_dict[\"createdAt\"] == \"2023-01-01T00:00:00Z\"\n    assert thread_dict[\"userId\"] == \"test_user_id\"\n    assert thread_dict[\"userIdentifier\"] == \"test_user_identifier_\"\n    assert thread_dict[\"tags\"] == [\"tag1\", \"tag2\"]\n    assert thread_dict[\"metadata\"] == {\"key\": \"value\"}\n    assert thread_dict[\"steps\"] is not None\n    assert len(thread_dict[\"steps\"]) == 1\n    assert thread_dict[\"elements\"] is not None\n    assert len(thread_dict[\"elements\"]) == 1\n"
  },
  {
    "path": "backend/tests/data/test_sql_alchemy.py",
    "content": "import uuid\nfrom pathlib import Path\n\nimport pytest\nfrom sqlalchemy import text\nfrom sqlalchemy.ext.asyncio import create_async_engine\n\nfrom chainlit import User\nfrom chainlit.data.sql_alchemy import SQLAlchemyDataLayer\nfrom chainlit.data.storage_clients.base import BaseStorageClient\nfrom chainlit.element import Text\n\n\n@pytest.fixture\nasync def data_layer(mock_storage_client: BaseStorageClient, tmp_path: Path):\n    db_file = tmp_path / \"test_db.sqlite\"\n    conninfo = f\"sqlite+aiosqlite:///{db_file}\"\n\n    # Create async engine\n    engine = create_async_engine(conninfo)\n\n    # Execute initialization statements\n    # Ref: https://docs.chainlit.io/data-persistence/custom#sql-alchemy-data-layer\n    async with engine.begin() as conn:\n        await conn.execute(\n            text(\n                \"\"\"\n                CREATE TABLE users (\n                    \"id\" UUID PRIMARY KEY,\n                    \"identifier\" TEXT NOT NULL UNIQUE,\n                    \"metadata\" JSONB NOT NULL,\n                    \"createdAt\" TEXT\n                );\n        \"\"\"\n            )\n        )\n\n        await conn.execute(\n            text(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS threads (\n                    \"id\" UUID PRIMARY KEY,\n                    \"createdAt\" TEXT,\n                    \"name\" TEXT,\n                    \"userId\" UUID,\n                    \"userIdentifier\" TEXT,\n                    \"tags\" TEXT[],\n                    \"metadata\" JSONB,\n                    FOREIGN KEY (\"userId\") REFERENCES users(\"id\") ON DELETE CASCADE\n                );\n        \"\"\"\n            )\n        )\n\n        await conn.execute(\n            text(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS steps (\n                    \"id\" UUID PRIMARY KEY,\n                    \"name\" TEXT NOT NULL,\n                    \"type\" TEXT NOT NULL,\n                    \"threadId\" UUID NOT NULL,\n                    \"parentId\" UUID,\n                    \"disableFeedback\" BOOLEAN NOT NULL,\n                    \"streaming\" BOOLEAN NOT NULL,\n                    \"waitForAnswer\" BOOLEAN,\n                    \"isError\" BOOLEAN,\n                    \"metadata\" JSONB,\n                    \"tags\" TEXT[],\n                    \"input\" TEXT,\n                    \"output\" TEXT,\n                    \"createdAt\" TEXT,\n                    \"start\" TEXT,\n                    \"end\" TEXT,\n                    \"generation\" JSONB,\n                    \"showInput\" TEXT,\n                    \"language\" TEXT,\n                    \"indent\" INT\n                );\n        \"\"\"\n            )\n        )\n\n        await conn.execute(\n            text(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS elements (\n                    \"id\" UUID PRIMARY KEY,\n                    \"threadId\" UUID,\n                    \"type\" TEXT,\n                    \"url\" TEXT,\n                    \"chainlitKey\" TEXT,\n                    \"name\" TEXT NOT NULL,\n                    \"display\" TEXT,\n                    \"objectKey\" TEXT,\n                    \"size\" TEXT,\n                    \"page\" INT,\n                    \"language\" TEXT,\n                    \"forId\" UUID,\n                    \"mime\" TEXT\n                );\n        \"\"\"\n            )\n        )\n\n        await conn.execute(\n            text(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS feedbacks (\n                    \"id\" UUID PRIMARY KEY,\n                    \"forId\" UUID NOT NULL,\n                    \"threadId\" UUID NOT NULL,\n                    \"value\" INT NOT NULL,\n                    \"comment\" TEXT\n                );\n        \"\"\"\n            )\n        )\n\n    # Create SQLAlchemyDataLayer instance\n    data_layer = SQLAlchemyDataLayer(conninfo, storage_provider=mock_storage_client)\n\n    return data_layer\n\n\nasync def test_create_and_get_element(\n    mock_chainlit_context, data_layer: SQLAlchemyDataLayer\n):\n    async with mock_chainlit_context:\n        text_element = Text(\n            id=str(uuid.uuid4()),\n            name=\"test.txt\",\n            mime=\"text/plain\",\n            content=\"test content\",\n            for_id=\"test_step_id\",\n        )\n\n        # Needs context because of wrapper in utils.py\n        await data_layer.create_element(text_element)\n\n    retrieved_element = await data_layer.get_element(\n        text_element.thread_id, text_element.id\n    )\n    assert retrieved_element is not None\n    assert retrieved_element[\"id\"] == text_element.id\n    assert retrieved_element[\"name\"] == text_element.name\n    assert retrieved_element[\"mime\"] == text_element.mime\n    # The 'content' field is not part of the ElementDict, so we remove this assertion\n\n\nasync def test_get_current_timestamp(data_layer: SQLAlchemyDataLayer):\n    timestamp = await data_layer.get_current_timestamp()\n    assert isinstance(timestamp, str)\n\n\nasync def test_get_user(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n    assert persisted_user\n\n    fetched_user = await data_layer.get_user(persisted_user.identifier)\n\n    assert fetched_user\n    assert fetched_user.createdAt == persisted_user.createdAt\n    assert fetched_user.id == persisted_user.id\n\n    nonexistent_user = await data_layer.get_user(\"nonexistent\")\n    assert nonexistent_user is None\n\n\nasync def test_create_user(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n\n    assert persisted_user\n    assert persisted_user.identifier == test_user.identifier\n    assert persisted_user.createdAt\n    assert persisted_user.id\n\n    # Assert id is valid uuid\n    assert uuid.UUID(persisted_user.id)\n\n\nasync def test_update_thread(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n    assert persisted_user\n\n    await data_layer.update_thread(\"test_thread\")\n\n\nasync def test_get_thread_author(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n    assert persisted_user\n\n    await data_layer.update_thread(\"test_thread\", user_id=persisted_user.id)\n    author = await data_layer.get_thread_author(\"test_thread\")\n\n    assert author == persisted_user.identifier\n\n\nasync def test_get_thread(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n    assert persisted_user\n\n    await data_layer.update_thread(\"test_thread\")\n    result = await data_layer.get_thread(\"test_thread\")\n    assert result is not None\n\n    result = await data_layer.get_thread(\"nonexisting_thread\")\n    assert result is None\n\n\nasync def test_delete_thread(test_user: User, data_layer: SQLAlchemyDataLayer):\n    persisted_user = await data_layer.create_user(test_user)\n    assert persisted_user\n\n    await data_layer.update_thread(\"test_thread\", \"test_user\")\n    await data_layer.delete_thread(\"test_thread\")\n    thread = await data_layer.get_thread(\"test_thread\")\n    assert thread is None\n"
  },
  {
    "path": "backend/tests/langchain/__init__.py",
    "content": ""
  },
  {
    "path": "backend/tests/langchain/test_async_callback.py",
    "content": "\"\"\"Tests for async LangChain callback handlers.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import AsyncMock, Mock, patch\nfrom uuid import uuid4\n\nimport pytest\nfrom langchain_core.outputs import GenerationChunk\n\nfrom chainlit.langchain.callbacks import LangchainTracer\nfrom chainlit.step import Step\n\n\ndef create_mock_run(**kwargs):\n    \"\"\"Helper to create a properly mocked Run object with all required attributes.\"\"\"\n    run = Mock()\n    run.id = kwargs.get(\"id\", uuid4())\n    run.parent_run_id = kwargs.get(\"parent_run_id\", None)\n    run.name = kwargs.get(\"name\", \"test_run\")\n    run.run_type = kwargs.get(\"run_type\", \"llm\")\n    run.tags = kwargs.get(\"tags\", [])\n    run.inputs = kwargs.get(\"inputs\", {})\n    run.outputs = kwargs.get(\"outputs\", None)\n    run.serialized = kwargs.get(\"serialized\", {})\n    run.extra = kwargs.get(\"extra\", {})\n    run.start_time = kwargs.get(\"start_time\", datetime.now())\n    run.end_time = kwargs.get(\"end_time\", None)\n    run.error = kwargs.get(\"error\", None)\n    return run\n\n\n@pytest.fixture\ndef mock_run():\n    \"\"\"Create a mock LangChain Run object.\"\"\"\n    return create_mock_run(\n        name=\"test_run\",\n        run_type=\"llm\",\n        inputs={\"input\": \"test input\"},\n        serialized={\"name\": \"test_llm\"},\n    )\n\n\nasync def test_tracer_initialization(mock_chainlit_context):\n    \"\"\"Test LangchainTracer initialization.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        assert tracer.steps == {}\n        assert tracer.parent_id_map == {}\n        assert tracer.ignored_runs == set()\n        assert tracer.stream_final_answer is False\n        assert tracer.answer_reached is False\n\n\nasync def test_on_llm_start(mock_chainlit_context):\n    \"\"\"Test on_llm_start callback.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n        run_id = uuid4()\n        prompts = [\"Test prompt\"]\n\n        await tracer.on_llm_start(\n            serialized={\"name\": \"test_llm\"},\n            prompts=prompts,\n            run_id=run_id,\n        )\n\n        assert str(run_id) in tracer.completion_generations\n        completion_gen = tracer.completion_generations[str(run_id)]\n        assert completion_gen[\"prompt\"] == \"Test prompt\"\n        assert completion_gen[\"token_count\"] == 0\n        assert completion_gen[\"tt_first_token\"] is None\n        assert \"start\" in completion_gen\n\n\nasync def test_on_llm_new_token(mock_chainlit_context):\n    \"\"\"Test on_llm_new_token with token streaming.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n        run_id = uuid4()\n\n        await tracer.on_llm_start(\n            serialized={\"name\": \"test_llm\"},\n            prompts=[\"Test prompt\"],\n            run_id=run_id,\n        )\n\n        chunk = GenerationChunk(text=\"Hello\")\n        await tracer.on_llm_new_token(\n            token=\"Hello\",\n            chunk=chunk,\n            run_id=run_id,\n        )\n\n        completion_gen = tracer.completion_generations[str(run_id)]\n        assert completion_gen[\"token_count\"] == 1\n        assert completion_gen[\"tt_first_token\"] is not None\n\n\nasync def test_start_trace(mock_chainlit_context):\n    \"\"\"Test _start_trace creates steps correctly.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        # Test LLM run\n        llm_run = create_mock_run(\n            id=uuid4(),\n            name=\"test_llm\",\n            run_type=\"llm\",\n            inputs={\"input\": \"test\"},\n        )\n\n        with patch.object(Step, \"send\", new_callable=AsyncMock):\n            await tracer._start_trace(llm_run)\n\n        assert str(llm_run.id) in tracer.steps\n        assert tracer.steps[str(llm_run.id)].type == \"llm\"\n\n        # Test ignored run\n        ignored_run = create_mock_run(\n            id=uuid4(),\n            name=\"RunnableSequence\",\n            run_type=\"chain\",\n        )\n        tracer.to_ignore = [\"RunnableSequence\"]\n        await tracer._start_trace(ignored_run)\n\n        assert str(ignored_run.id) in tracer.ignored_runs\n\n\nasync def test_on_run_update(mock_chainlit_context):\n    \"\"\"Test _on_run_update updates steps.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n        run_id = uuid4()\n\n        step = Step(id=str(run_id), name=\"test_tool\", type=\"tool\")\n        tracer.steps[str(run_id)] = step\n\n        run = create_mock_run(\n            id=run_id,\n            name=\"test_tool\",\n            run_type=\"tool\",\n            outputs={\"output\": \"result\"},\n        )\n\n        with patch.object(step, \"update\", new_callable=AsyncMock):\n            await tracer._on_run_update(run)\n\n        assert step.output is not None\n\n\nasync def test_error_handling(mock_chainlit_context):\n    \"\"\"Test error handling in callbacks.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n        run_id = uuid4()\n\n        step = Step(id=str(run_id), name=\"test_step\", type=\"llm\")\n        tracer.steps[str(run_id)] = step\n\n        error = ValueError(\"Test error\")\n\n        with patch.object(step, \"update\", new_callable=AsyncMock):\n            await tracer._on_error(error, run_id=run_id)\n\n        assert step.is_error is True\n        assert step.output == \"Test error\"\n"
  },
  {
    "path": "backend/tests/langchain/test_chain_types.py",
    "content": "\"\"\"Tests for different LangChain chain types and their integration with Chainlit.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import AsyncMock, Mock, patch\nfrom uuid import uuid4\n\nfrom chainlit.langchain.callbacks import LangchainTracer\nfrom chainlit.step import Step\n\n\ndef create_mock_run(**kwargs):\n    \"\"\"Helper to create a properly mocked Run object with all required attributes.\"\"\"\n    run = Mock()\n    run.id = kwargs.get(\"id\", uuid4())\n    run.parent_run_id = kwargs.get(\"parent_run_id\", None)\n    run.name = kwargs.get(\"name\", \"test_run\")\n    run.run_type = kwargs.get(\"run_type\", \"llm\")\n    run.tags = kwargs.get(\"tags\", [])\n    run.inputs = kwargs.get(\"inputs\", {})\n    run.outputs = kwargs.get(\"outputs\", None)\n    run.serialized = kwargs.get(\"serialized\", {})\n    run.extra = kwargs.get(\"extra\", {})\n    run.start_time = kwargs.get(\"start_time\", datetime.now())\n    run.end_time = kwargs.get(\"end_time\", None)\n    run.error = kwargs.get(\"error\", None)\n    return run\n\n\nasync def test_different_run_types(mock_chainlit_context):\n    \"\"\"Test different LangChain run types create correct steps.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        # Test agent run\n        agent_run = create_mock_run(\n            id=uuid4(),\n            name=\"test_agent\",\n            run_type=\"agent\",\n            inputs={\"input\": \"test\"},\n        )\n\n        with patch.object(Step, \"send\", new_callable=AsyncMock):\n            await tracer._start_trace(agent_run)\n\n        assert str(agent_run.id) in tracer.steps\n        assert tracer.steps[str(agent_run.id)].type == \"run\"\n\n        # Test tool run\n        tool_run = create_mock_run(\n            id=uuid4(),\n            name=\"test_tool\",\n            run_type=\"tool\",\n            inputs={\"query\": \"test\"},\n        )\n\n        with patch.object(Step, \"send\", new_callable=AsyncMock):\n            await tracer._start_trace(tool_run)\n\n        assert str(tool_run.id) in tracer.steps\n        assert tracer.steps[str(tool_run.id)].type == \"tool\"\n\n\nasync def test_nested_chain_hierarchy(mock_chainlit_context):\n    \"\"\"Test nested chain with LLM calls.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        # Create parent chain\n        chain_run = create_mock_run(\n            id=uuid4(),\n            name=\"parent_chain\",\n            run_type=\"chain\",\n            inputs={\"input\": \"test\"},\n        )\n\n        with patch.object(Step, \"send\", new_callable=AsyncMock):\n            await tracer._start_trace(chain_run)\n\n        # Create nested LLM\n        llm_run = create_mock_run(\n            id=uuid4(),\n            parent_run_id=chain_run.id,\n            name=\"nested_llm\",\n            run_type=\"llm\",\n            inputs={},\n        )\n\n        with patch.object(Step, \"send\", new_callable=AsyncMock):\n            await tracer._start_trace(llm_run)\n\n        # Both should exist\n        assert str(chain_run.id) in tracer.steps\n        assert str(llm_run.id) in tracer.steps\n\n        # LLM should have chain as parent\n        llm_step = tracer.steps[str(llm_run.id)]\n        assert llm_step.parent_id == str(chain_run.id)\n\n\nasync def test_ignored_runs(mock_chainlit_context):\n    \"\"\"Test that default ignored runs are properly filtered.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        # Test RunnableSequence is ignored\n        sequence_run = create_mock_run(\n            id=uuid4(),\n            name=\"RunnableSequence\",\n            run_type=\"chain\",\n            inputs={},\n        )\n\n        await tracer._start_trace(sequence_run)\n\n        assert str(sequence_run.id) not in tracer.steps\n        assert str(sequence_run.id) in tracer.ignored_runs\n\n\nasync def test_custom_filtering(mock_chainlit_context):\n    \"\"\"Test custom to_ignore and to_keep lists.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer(to_ignore=[\"CustomIgnore\"], to_keep=[\"llm\", \"tool\"])\n\n        # Test custom ignore\n        custom_run = create_mock_run(\n            id=uuid4(),\n            name=\"CustomIgnore\",\n            run_type=\"chain\",\n        )\n\n        await tracer._start_trace(custom_run)\n\n        assert str(custom_run.id) not in tracer.steps\n        assert str(custom_run.id) in tracer.ignored_runs\n"
  },
  {
    "path": "backend/tests/langchain/test_sync_callback.py",
    "content": "\"\"\"Tests for synchronous LangChain callback operations and helper classes.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import Mock\nfrom uuid import uuid4\n\nfrom langchain.schema import AIMessage, HumanMessage\n\nfrom chainlit.langchain.callbacks import (\n    FinalStreamHelper,\n    GenerationHelper,\n    LangchainTracer,\n)\n\n\ndef create_mock_run(**kwargs):\n    \"\"\"Helper to create a properly mocked Run object with all required attributes.\"\"\"\n    run = Mock()\n    run.id = kwargs.get(\"id\", uuid4())\n    run.parent_run_id = kwargs.get(\"parent_run_id\", None)\n    run.name = kwargs.get(\"name\", \"test_run\")\n    run.run_type = kwargs.get(\"run_type\", \"llm\")\n    run.tags = kwargs.get(\"tags\", [])\n    run.inputs = kwargs.get(\"inputs\", {})\n    run.outputs = kwargs.get(\"outputs\", None)\n    run.serialized = kwargs.get(\"serialized\", {})\n    run.extra = kwargs.get(\"extra\", {})\n    run.start_time = kwargs.get(\"start_time\", datetime.now())\n    run.end_time = kwargs.get(\"end_time\", None)\n    run.error = kwargs.get(\"error\", None)\n    return run\n\n\nclass TestFinalStreamHelper:\n    \"\"\"Test FinalStreamHelper class.\"\"\"\n\n    def test_initialization(self):\n        \"\"\"Test FinalStreamHelper initialization.\"\"\"\n        helper = FinalStreamHelper()\n\n        assert helper.answer_prefix_tokens == [\"Final\", \"Answer\", \":\"]\n        assert helper.stream_final_answer is False\n        assert helper.answer_reached is False\n\n    def test_check_if_answer_reached(self):\n        \"\"\"Test _check_if_answer_reached.\"\"\"\n        helper = FinalStreamHelper()\n\n        helper._append_to_last_tokens(\"Final\")\n        helper._append_to_last_tokens(\"Answer\")\n        helper._append_to_last_tokens(\":\")\n\n        assert helper._check_if_answer_reached() is True\n\n\nclass TestGenerationHelper:\n    \"\"\"Test GenerationHelper class.\"\"\"\n\n    def test_initialization(self):\n        \"\"\"Test GenerationHelper initialization.\"\"\"\n        helper = GenerationHelper()\n\n        assert helper.chat_generations == {}\n        assert helper.completion_generations == {}\n        assert helper.generation_inputs == {}\n\n    def test_ensure_values_serializable(self):\n        \"\"\"Test ensure_values_serializable method.\"\"\"\n        helper = GenerationHelper()\n\n        # Test dict\n        result = helper.ensure_values_serializable({\"key\": \"value\", \"num\": 42})\n        assert result == {\"key\": \"value\", \"num\": 42}\n\n        # Test list\n        result = helper.ensure_values_serializable([1, 2, \"three\"])\n        assert result == [1, 2, \"three\"]\n\n    def test_convert_message(self):\n        \"\"\"Test _convert_message method.\"\"\"\n        helper = GenerationHelper()\n\n        # Test HumanMessage\n        msg = HumanMessage(content=\"Hello\")\n        result = helper._convert_message(msg)\n        assert result[\"role\"] == \"user\"\n        assert result[\"content\"] == \"Hello\"\n\n        # Test AIMessage\n        msg = AIMessage(content=\"Hi there\")\n        result = helper._convert_message(msg)\n        assert result[\"role\"] == \"assistant\"\n        assert result[\"content\"] == \"Hi there\"\n\n    def test_build_llm_settings(self):\n        \"\"\"Test _build_llm_settings method.\"\"\"\n        helper = GenerationHelper()\n\n        serialized = {\"name\": \"ChatOpenAI\"}\n        invocation_params = {\n            \"_type\": \"openai\",\n            \"model\": \"gpt-4\",\n            \"temperature\": 0.7,\n        }\n\n        provider, model, _, settings = helper._build_llm_settings(\n            serialized, invocation_params\n        )\n\n        assert provider == \"openai\"\n        assert model == \"gpt-4\"\n        assert settings[\"temperature\"] == 0.7\n\n\nasync def test_should_ignore_run(mock_chainlit_context):\n    \"\"\"Test _should_ignore_run method.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer(to_ignore=[\"RunnableSequence\"])\n\n        # Test ignored by name\n        run = create_mock_run(name=\"RunnableSequence\", run_type=\"chain\")\n        ignore, _ = tracer._should_ignore_run(run)\n        assert ignore is True\n\n        # Test not ignored\n        run = create_mock_run(name=\"MyChain\", run_type=\"chain\")\n        ignore, _ = tracer._should_ignore_run(run)\n        assert ignore is False\n\n\nasync def test_get_non_ignored_parent_id(mock_chainlit_context):\n    \"\"\"Test _get_non_ignored_parent_id.\"\"\"\n    async with mock_chainlit_context:\n        tracer = LangchainTracer()\n\n        # Setup parent chain\n        grandparent_id = str(uuid4())\n        parent_id = str(uuid4())\n\n        # Both parent and grandparent need to be in parent_id_map for the traversal to work\n        tracer.parent_id_map[parent_id] = grandparent_id\n        tracer.parent_id_map[grandparent_id] = None  # grandparent has no parent\n        tracer.ignored_runs.add(parent_id)\n\n        result = tracer._get_non_ignored_parent_id(parent_id)\n\n        assert result == grandparent_id\n"
  },
  {
    "path": "backend/tests/llama_index/test_callbacks.py",
    "content": "from unittest.mock import patch\n\nfrom llama_index.core.callbacks.schema import CBEventType, EventPayload\nfrom llama_index.core.tools.types import ToolMetadata\n\nfrom chainlit.llama_index.callbacks import LlamaIndexCallbackHandler\nfrom chainlit.step import Step\n\n\nasync def test_on_event_start_for_function_calls(mock_chainlit_context):\n    TEST_EVENT_ID = \"test_event_id\"\n    async with mock_chainlit_context:\n        handler = LlamaIndexCallbackHandler()\n\n        with patch.object(Step, \"send\") as mock_send:\n            result = handler.on_event_start(\n                CBEventType.FUNCTION_CALL,\n                {\n                    EventPayload.TOOL: ToolMetadata(\n                        name=\"test_tool\", description=\"test_description\"\n                    ),\n                    EventPayload.FUNCTION_CALL: {\"arg1\": \"value1\"},\n                },\n                TEST_EVENT_ID,\n            )\n\n        assert result == TEST_EVENT_ID\n        assert TEST_EVENT_ID in handler.steps\n        step = handler.steps[TEST_EVENT_ID]\n        assert isinstance(step, Step)\n        assert step.name == \"test_tool\"\n        assert step.type == \"tool\"\n        assert step.id == TEST_EVENT_ID\n        assert step.input == '{\\n    \"arg1\": \"value1\"\\n}'\n        mock_send.assert_called_once()\n\n\nasync def test_on_event_start_for_function_calls_missing_payload(mock_chainlit_context):\n    TEST_EVENT_ID = \"test_event_id\"\n    async with mock_chainlit_context:\n        handler = LlamaIndexCallbackHandler()\n\n        with patch.object(Step, \"send\") as mock_send:\n            result = handler.on_event_start(\n                CBEventType.FUNCTION_CALL,\n                None,\n                TEST_EVENT_ID,\n            )\n\n        assert result == TEST_EVENT_ID\n        assert TEST_EVENT_ID in handler.steps\n        step = handler.steps[TEST_EVENT_ID]\n        assert isinstance(step, Step)\n        assert step.name == \"function_call\"\n        assert step.type == \"tool\"\n        assert step.id == TEST_EVENT_ID\n        assert step.input == \"{}\"\n        mock_send.assert_called_once()\n\n\nasync def test_on_event_end_for_function_calls(mock_chainlit_context):\n    TEST_EVENT_ID = \"test_event_id\"\n    async with mock_chainlit_context:\n        handler = LlamaIndexCallbackHandler()\n        # Pretend that we have started a step before.\n        step = Step(name=\"test_tool\", type=\"tool\", id=TEST_EVENT_ID)\n        handler.steps[TEST_EVENT_ID] = step\n\n        with patch.object(step, \"update\") as mock_send:\n            handler.on_event_end(\n                CBEventType.FUNCTION_CALL,\n                payload={EventPayload.FUNCTION_OUTPUT: \"test_output\"},\n                event_id=TEST_EVENT_ID,\n            )\n\n        assert step.output == \"test_output\"\n        assert TEST_EVENT_ID not in handler.steps\n        mock_send.assert_called_once()\n\n\nasync def test_on_event_end_for_function_calls_missing_payload(mock_chainlit_context):\n    TEST_EVENT_ID = \"test_event_id\"\n    async with mock_chainlit_context:\n        handler = LlamaIndexCallbackHandler()\n        # Pretend that we have started a step before.\n        step = Step(name=\"test_tool\", type=\"tool\", id=TEST_EVENT_ID)\n        handler.steps[TEST_EVENT_ID] = step\n\n        with patch.object(step, \"update\") as mock_send:\n            handler.on_event_end(\n                CBEventType.FUNCTION_CALL,\n                payload=None,\n                event_id=TEST_EVENT_ID,\n            )\n        # 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?\n        assert TEST_EVENT_ID in handler.steps\n        mock_send.assert_not_called()\n"
  },
  {
    "path": "backend/tests/test_action.py",
    "content": "import uuid\n\nimport pytest\n\nfrom chainlit.action import Action\n\n\n@pytest.mark.asyncio\nclass TestAction:\n    \"\"\"Test suite for the Action class.\"\"\"\n\n    async def test_action_initialization_with_required_fields(self):\n        \"\"\"Test Action initialization with only required fields.\"\"\"\n        action = Action(\n            name=\"test_action\",\n            payload={\"key\": \"value\"},\n        )\n\n        assert action.name == \"test_action\"\n        assert action.payload == {\"key\": \"value\"}\n        assert action.label == \"\"\n        assert action.tooltip == \"\"\n        assert action.icon is None\n        assert action.forId is None\n        assert isinstance(action.id, str)\n        # Verify ID is a valid UUID\n        uuid.UUID(action.id)\n\n    async def test_action_initialization_with_all_fields(self):\n        \"\"\"Test Action initialization with all fields provided.\"\"\"\n        test_id = str(uuid.uuid4())\n        action = Action(\n            name=\"custom_action\",\n            payload={\"param1\": \"value1\", \"param2\": 42},\n            label=\"Click Me\",\n            tooltip=\"This is a tooltip\",\n            icon=\"check-circle\",\n            id=test_id,\n        )\n\n        assert action.name == \"custom_action\"\n        assert action.payload == {\"param1\": \"value1\", \"param2\": 42}\n        assert action.label == \"Click Me\"\n        assert action.tooltip == \"This is a tooltip\"\n        assert action.icon == \"check-circle\"\n        assert action.id == test_id\n        assert action.forId is None\n\n    async def test_action_id_auto_generation(self):\n        \"\"\"Test that Action generates unique IDs automatically.\"\"\"\n        action1 = Action(name=\"action1\", payload={})\n        action2 = Action(name=\"action2\", payload={})\n\n        assert action1.id != action2.id\n        # Verify both are valid UUIDs\n        uuid.UUID(action1.id)\n        uuid.UUID(action2.id)\n\n    async def test_action_to_dict(self):\n        \"\"\"Test Action serialization to dictionary.\"\"\"\n        test_id = str(uuid.uuid4())\n        action = Action(\n            name=\"test_action\",\n            payload={\"data\": \"test\"},\n            label=\"Test Label\",\n            tooltip=\"Test Tooltip\",\n            icon=\"star\",\n            id=test_id,\n        )\n\n        action_dict = action.to_dict()\n\n        assert action_dict[\"name\"] == \"test_action\"\n        assert action_dict[\"payload\"] == {\"data\": \"test\"}\n        assert action_dict[\"label\"] == \"Test Label\"\n        assert action_dict[\"tooltip\"] == \"Test Tooltip\"\n        assert action_dict[\"icon\"] == \"star\"\n        assert action_dict[\"id\"] == test_id\n        assert action_dict.get(\"forId\") is None\n\n    async def test_action_to_dict_with_for_id(self):\n        \"\"\"Test Action serialization includes forId when set.\"\"\"\n        action = Action(name=\"test\", payload={})\n        action.forId = \"message_123\"\n\n        action_dict = action.to_dict()\n\n        assert action_dict[\"forId\"] == \"message_123\"\n\n    async def test_action_send(self, mock_chainlit_context):\n        \"\"\"Test Action.send() method emits correct event.\"\"\"\n        async with mock_chainlit_context as ctx:\n            action = Action(\n                name=\"send_action\",\n                payload={\"test\": \"data\"},\n                label=\"Send Test\",\n            )\n\n            for_id = \"target_message_id\"\n            await action.send(for_id=for_id)\n\n            # Verify forId was set\n            assert action.forId == for_id\n\n            # Verify emit was called with correct parameters\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"action\"\n\n            emitted_dict = call_args[0][1]\n            assert emitted_dict[\"name\"] == \"send_action\"\n            assert emitted_dict[\"payload\"] == {\"test\": \"data\"}\n            assert emitted_dict[\"label\"] == \"Send Test\"\n            assert emitted_dict[\"forId\"] == for_id\n\n    async def test_action_send_updates_for_id(self, mock_chainlit_context):\n        \"\"\"Test that send() updates the forId field.\"\"\"\n        async with mock_chainlit_context:\n            action = Action(name=\"test\", payload={})\n\n            # Initially forId should be None\n            assert action.forId is None\n\n            # Send with first for_id\n            await action.send(for_id=\"first_id\")\n            assert action.forId == \"first_id\"\n\n            # Send with different for_id should update\n            await action.send(for_id=\"second_id\")\n            assert action.forId == \"second_id\"\n\n    async def test_action_remove(self, mock_chainlit_context):\n        \"\"\"Test Action.remove() method emits correct event.\"\"\"\n        async with mock_chainlit_context as ctx:\n            action = Action(\n                name=\"remove_action\",\n                payload={\"key\": \"value\"},\n                label=\"Remove Test\",\n            )\n            action.forId = \"message_123\"\n\n            await action.remove()\n\n            # Verify emit was called with correct parameters\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"remove_action\"\n\n            emitted_dict = call_args[0][1]\n            assert emitted_dict[\"name\"] == \"remove_action\"\n            assert emitted_dict[\"payload\"] == {\"key\": \"value\"}\n            assert emitted_dict[\"forId\"] == \"message_123\"\n\n    async def test_action_remove_without_for_id(self, mock_chainlit_context):\n        \"\"\"Test Action.remove() works even without forId set.\"\"\"\n        async with mock_chainlit_context as ctx:\n            action = Action(name=\"test\", payload={})\n\n            await action.remove()\n\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"remove_action\"\n\n    async def test_action_with_complex_payload(self):\n        \"\"\"Test Action with complex nested payload.\"\"\"\n        complex_payload = {\n            \"nested\": {\n                \"data\": [1, 2, 3],\n                \"info\": {\"key\": \"value\"},\n            },\n            \"list\": [\"a\", \"b\", \"c\"],\n            \"number\": 42,\n            \"boolean\": True,\n            \"null\": None,\n        }\n\n        action = Action(name=\"complex\", payload=complex_payload)\n\n        assert action.payload == complex_payload\n        action_dict = action.to_dict()\n        assert action_dict[\"payload\"] == complex_payload\n\n    async def test_action_with_empty_payload(self):\n        \"\"\"Test Action with empty payload.\"\"\"\n        action = Action(name=\"empty\", payload={})\n\n        assert action.payload == {}\n        assert action.to_dict()[\"payload\"] == {}\n\n    async def test_action_with_empty_strings(self):\n        \"\"\"Test Action handles empty strings correctly.\"\"\"\n        action = Action(\n            name=\"test\",\n            payload={},\n            label=\"\",\n            tooltip=\"\",\n        )\n\n        assert action.label == \"\"\n        assert action.tooltip == \"\"\n\n        action_dict = action.to_dict()\n        assert action_dict[\"label\"] == \"\"\n        assert action_dict[\"tooltip\"] == \"\"\n\n    async def test_action_serialization_deserialization(self):\n        \"\"\"Test Action can be serialized and deserialized.\"\"\"\n        original = Action(\n            name=\"serialize_test\",\n            payload={\"data\": \"test\"},\n            label=\"Test\",\n            tooltip=\"Tooltip\",\n            icon=\"icon-name\",\n        )\n\n        # Serialize to dict\n        serialized = original.to_dict()\n\n        # Deserialize from dict\n        deserialized = Action.from_dict(serialized)\n\n        assert deserialized.name == original.name\n        assert deserialized.payload == original.payload\n        assert deserialized.label == original.label\n        assert deserialized.tooltip == original.tooltip\n        assert deserialized.icon == original.icon\n        assert deserialized.id == original.id\n\n    async def test_multiple_actions_with_same_name(self):\n        \"\"\"Test that multiple actions can have the same name but different IDs.\"\"\"\n        action1 = Action(name=\"duplicate\", payload={\"num\": 1})\n        action2 = Action(name=\"duplicate\", payload={\"num\": 2})\n\n        assert action1.name == action2.name\n        assert action1.id != action2.id\n        assert action1.payload != action2.payload\n\n    async def test_action_send_multiple_times(self, mock_chainlit_context):\n        \"\"\"Test that an action can be sent multiple times.\"\"\"\n        async with mock_chainlit_context as ctx:\n            action = Action(name=\"multi_send\", payload={})\n\n            await action.send(for_id=\"id1\")\n            await action.send(for_id=\"id2\")\n            await action.send(for_id=\"id3\")\n\n            # Should have been called 3 times\n            assert ctx.emitter.emit.call_count == 3\n\n            # Last forId should be id3\n            assert action.forId == \"id3\"\n\n    async def test_action_with_special_characters_in_payload(self):\n        \"\"\"Test Action handles special characters in payload.\"\"\"\n        special_payload = {\n            \"unicode\": \"Hello 世界 🌍\",\n            \"quotes\": 'He said \"Hello\"',\n            \"newlines\": \"Line1\\nLine2\\nLine3\",\n            \"tabs\": \"Col1\\tCol2\\tCol3\",\n        }\n\n        action = Action(name=\"special\", payload=special_payload)\n\n        assert action.payload == special_payload\n        action_dict = action.to_dict()\n        assert action_dict[\"payload\"] == special_payload\n\n    async def test_action_icon_variations(self):\n        \"\"\"Test Action with different icon values.\"\"\"\n        # With icon\n        action_with_icon = Action(name=\"test\", payload={}, icon=\"check\")\n        assert action_with_icon.icon == \"check\"\n\n        # Without icon (None)\n        action_no_icon = Action(name=\"test\", payload={}, icon=None)\n        assert action_no_icon.icon is None\n\n        # Default (should be None)\n        action_default = Action(name=\"test\", payload={})\n        assert action_default.icon is None\n"
  },
  {
    "path": "backend/tests/test_cache.py",
    "content": "import sys\nimport threading\nfrom unittest.mock import Mock, patch\n\nimport pytest\n\nfrom chainlit.cache import cache, init_lc_cache\n\n# Import the actual cache module to access _cache dict\ncache_module = sys.modules[\"chainlit.cache\"]\n\n\nclass TestCacheDecorator:\n    \"\"\"Test suite for the @cache decorator.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Clear the cache before each test.\"\"\"\n        cache_module._cache.clear()\n\n    def teardown_method(self):\n        \"\"\"Clear the cache after each test.\"\"\"\n        cache_module._cache.clear()\n\n    def test_cache_basic_function(self):\n        \"\"\"Test that cache decorator caches function results.\"\"\"\n        call_count = 0\n\n        @cache\n        def add(a, b):\n            nonlocal call_count\n            call_count += 1\n            return a + b\n\n        # First call\n        result1 = add(2, 3)\n        assert result1 == 5\n        assert call_count == 1\n\n        # Second call with same args should use cache\n        result2 = add(2, 3)\n        assert result2 == 5\n        assert call_count == 1  # Function not called again\n\n    def test_cache_different_arguments(self):\n        \"\"\"Test that different arguments create different cache entries.\"\"\"\n        call_count = 0\n\n        @cache\n        def multiply(x, y):\n            nonlocal call_count\n            call_count += 1\n            return x * y\n\n        result1 = multiply(2, 3)\n        assert result1 == 6\n        assert call_count == 1\n\n        result2 = multiply(4, 5)\n        assert result2 == 20\n        assert call_count == 2  # Different args, function called again\n\n        # Same args as first call, should use cache\n        result3 = multiply(2, 3)\n        assert result3 == 6\n        assert call_count == 2  # Cache hit, no new call\n\n    def test_cache_with_kwargs(self):\n        \"\"\"Test that cache works with keyword arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def greet(name, greeting=\"Hello\"):\n            nonlocal call_count\n            call_count += 1\n            return f\"{greeting}, {name}!\"\n\n        result1 = greet(\"Alice\", greeting=\"Hi\")\n        assert result1 == \"Hi, Alice!\"\n        assert call_count == 1\n\n        # Same call should use cache\n        result2 = greet(\"Alice\", greeting=\"Hi\")\n        assert result2 == \"Hi, Alice!\"\n        assert call_count == 1\n\n        # Different kwargs should call function\n        result3 = greet(\"Alice\", greeting=\"Hello\")\n        assert result3 == \"Hello, Alice!\"\n        assert call_count == 2\n\n    def test_cache_kwargs_order_independence(self):\n        \"\"\"Test that kwargs order doesn't affect cache key.\"\"\"\n        call_count = 0\n\n        @cache\n        def func(a=1, b=2, c=3):\n            nonlocal call_count\n            call_count += 1\n            return a + b + c\n\n        result1 = func(a=1, b=2, c=3)\n        assert result1 == 6\n        assert call_count == 1\n\n        # Same kwargs, different order - should use cache\n        result2 = func(c=3, a=1, b=2)\n        assert result2 == 6\n        assert call_count == 1  # Cache hit\n\n    def test_cache_mixed_args_and_kwargs(self):\n        \"\"\"Test cache with both positional and keyword arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def compute(x, y, z=10):\n            nonlocal call_count\n            call_count += 1\n            return x + y + z\n\n        result1 = compute(1, 2, z=3)\n        assert result1 == 6\n        assert call_count == 1\n\n        result2 = compute(1, 2, z=3)\n        assert result2 == 6\n        assert call_count == 1  # Cache hit\n\n        result3 = compute(1, 2, z=5)\n        assert result3 == 8\n        assert call_count == 2  # Different z value\n\n    def test_cache_with_no_arguments(self):\n        \"\"\"Test cache with functions that take no arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def get_constant():\n            nonlocal call_count\n            call_count += 1\n            return 42\n\n        result1 = get_constant()\n        assert result1 == 42\n        assert call_count == 1\n\n        result2 = get_constant()\n        assert result2 == 42\n        assert call_count == 1  # Cache hit\n\n    def test_cache_with_mutable_return_value(self):\n        \"\"\"Test that cache returns the same object reference.\"\"\"\n        call_count = 0\n\n        @cache\n        def get_list():\n            nonlocal call_count\n            call_count += 1\n            return [1, 2, 3]\n\n        result1 = get_list()\n        assert result1 == [1, 2, 3]\n        assert call_count == 1\n\n        result2 = get_list()\n        assert result2 == [1, 2, 3]\n        assert call_count == 1\n\n        # Both results should be the same object\n        assert result1 is result2\n\n    def test_cache_thread_safety(self):\n        \"\"\"Test that cache is thread-safe.\"\"\"\n        call_count = 0\n        call_lock = threading.Lock()\n\n        @cache\n        def slow_function(x):\n            nonlocal call_count\n            with call_lock:\n                call_count += 1\n            return x * 2\n\n        results = []\n\n        def worker():\n            result = slow_function(5)\n            results.append(result)\n\n        threads = [threading.Thread(target=worker) for _ in range(10)]\n        for t in threads:\n            t.start()\n        for t in threads:\n            t.join()\n\n        # All results should be the same\n        assert all(r == 10 for r in results)\n        # Function should only be called once despite multiple threads\n        assert call_count == 1\n\n    def test_cache_different_functions_same_args(self):\n        \"\"\"Test that different functions with same args have separate cache.\"\"\"\n        call_count_1 = 0\n        call_count_2 = 0\n\n        @cache\n        def func1(x):\n            nonlocal call_count_1\n            call_count_1 += 1\n            return x + 1\n\n        @cache\n        def func2(x):\n            nonlocal call_count_2\n            call_count_2 += 1\n            return x + 2\n\n        result1 = func1(5)\n        assert result1 == 6\n        assert call_count_1 == 1\n\n        result2 = func2(5)\n        assert result2 == 7\n        assert call_count_2 == 1\n\n        # Both should use their own cache\n        func1(5)\n        func2(5)\n        assert call_count_1 == 1\n        assert call_count_2 == 1\n\n    def test_cache_with_none_arguments(self):\n        \"\"\"Test cache with None as argument.\"\"\"\n        call_count = 0\n\n        @cache\n        def process(value):\n            nonlocal call_count\n            call_count += 1\n            return value\n\n        result1 = process(None)\n        assert result1 is None\n        assert call_count == 1\n\n        result2 = process(None)\n        assert result2 is None\n        assert call_count == 1  # Cache hit\n\n    def test_cache_preserves_function_behavior(self):\n        \"\"\"Test that cache decorator preserves original function behavior.\"\"\"\n\n        @cache\n        def divide(a, b):\n            return a / b\n\n        assert divide(10, 2) == 5.0\n        assert divide(10, 2) == 5.0  # Cached\n\n        with pytest.raises(ZeroDivisionError):\n            divide(10, 0)\n\n\nclass TestInitLcCache:\n    \"\"\"Test suite for init_lc_cache function.\"\"\"\n\n    def test_init_lc_cache_disabled_by_config(self):\n        \"\"\"Test that cache is not initialized when disabled in config.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = False\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = False\n\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\"\n                ) as mock_find_spec:\n                    init_lc_cache()\n\n                    # Should not check for langchain if cache is disabled\n                    mock_find_spec.assert_not_called()\n\n    def test_init_lc_cache_disabled_by_no_cache_flag(self):\n        \"\"\"Test that cache is not initialized when no_cache flag is set.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = True\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = True\n\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\"\n                ) as mock_find_spec:\n                    init_lc_cache()\n\n                    # Should not check for langchain if no_cache is True\n                    mock_find_spec.assert_not_called()\n\n    def test_init_lc_cache_langchain_not_installed(self):\n        \"\"\"Test behavior when langchain is not installed.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = True\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = False\n\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\", return_value=None\n                ) as mock_find_spec:\n                    # Should not raise an error\n                    init_lc_cache()\n\n                    mock_find_spec.assert_called_once_with(\"langchain\")\n\n    def test_init_lc_cache_with_langchain_installed(self):\n        \"\"\"Test cache initialization when langchain is installed.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = True\n            mock_project.lc_cache_path = \"/tmp/test_cache.db\"\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = False\n\n                mock_spec = Mock()\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\", return_value=mock_spec\n                ):\n                    # Mock langchain modules\n                    mock_sqlite_cache = Mock()\n                    mock_set_llm_cache = Mock()\n\n                    with patch.dict(\n                        sys.modules,\n                        {\n                            \"langchain\": Mock(),\n                            \"langchain.cache\": Mock(SQLiteCache=mock_sqlite_cache),\n                            \"langchain.globals\": Mock(set_llm_cache=mock_set_llm_cache),\n                        },\n                    ):\n                        with patch(\"os.path.exists\", return_value=True):\n                            init_lc_cache()\n\n                            mock_sqlite_cache.assert_called_once_with(\n                                database_path=\"/tmp/test_cache.db\"\n                            )\n                            mock_set_llm_cache.assert_called_once()\n\n    def test_init_lc_cache_creates_new_cache_file(self):\n        \"\"\"Test that logger is called when creating new cache file.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = True\n            mock_project.lc_cache_path = \"/tmp/new_cache.db\"\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = False\n\n                mock_spec = Mock()\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\", return_value=mock_spec\n                ):\n                    mock_sqlite_cache = Mock()\n                    mock_set_llm_cache = Mock()\n\n                    with patch.dict(\n                        sys.modules,\n                        {\n                            \"langchain\": Mock(),\n                            \"langchain.cache\": Mock(SQLiteCache=mock_sqlite_cache),\n                            \"langchain.globals\": Mock(set_llm_cache=mock_set_llm_cache),\n                        },\n                    ):\n                        with patch(\"os.path.exists\", return_value=False):\n                            with patch.object(cache_module, \"logger\") as mock_logger:\n                                init_lc_cache()\n\n                                mock_logger.info.assert_called_once()\n                                assert \"LangChain cache created at\" in str(\n                                    mock_logger.info.call_args\n                                )\n\n    def test_init_lc_cache_without_cache_path(self):\n        \"\"\"Test that cache is not initialized when cache path is None.\"\"\"\n        with patch.object(cache_module.config, \"project\") as mock_project:\n            mock_project.cache = True\n            mock_project.lc_cache_path = None\n            with patch.object(cache_module.config, \"run\") as mock_run:\n                mock_run.no_cache = False\n\n                mock_spec = Mock()\n                with patch.object(\n                    cache_module.importlib.util, \"find_spec\", return_value=mock_spec\n                ):\n                    mock_sqlite_cache = Mock()\n                    mock_set_llm_cache = Mock()\n\n                    with patch.dict(\n                        sys.modules,\n                        {\n                            \"langchain\": Mock(),\n                            \"langchain.cache\": Mock(SQLiteCache=mock_sqlite_cache),\n                            \"langchain.globals\": Mock(set_llm_cache=mock_set_llm_cache),\n                        },\n                    ):\n                        init_lc_cache()\n\n                        # Should not call SQLiteCache if path is None\n                        mock_sqlite_cache.assert_not_called()\n                        mock_set_llm_cache.assert_not_called()\n\n\nclass TestCacheEdgeCases:\n    \"\"\"Test suite for cache edge cases.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Clear the cache before each test.\"\"\"\n        cache_module._cache.clear()\n\n    def teardown_method(self):\n        \"\"\"Clear the cache after each test.\"\"\"\n        cache_module._cache.clear()\n\n    def test_cache_with_unhashable_arguments(self):\n        \"\"\"Test that cache handles unhashable arguments gracefully.\"\"\"\n\n        @cache\n        def process_list(items):\n            return sum(items)\n\n        # Lists are unhashable and will cause an error\n        with pytest.raises(TypeError):\n            process_list([1, 2, 3])\n\n    def test_cache_with_string_arguments(self):\n        \"\"\"Test cache with string arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def process_string(s):\n            nonlocal call_count\n            call_count += 1\n            return s.upper()\n\n        result1 = process_string(\"hello\")\n        assert result1 == \"HELLO\"\n        assert call_count == 1\n\n        result2 = process_string(\"hello\")\n        assert result2 == \"HELLO\"\n        assert call_count == 1  # Cache hit\n\n    def test_cache_with_tuple_arguments(self):\n        \"\"\"Test cache with tuple arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def process_tuple(t):\n            nonlocal call_count\n            call_count += 1\n            return sum(t)\n\n        result1 = process_tuple((1, 2, 3))\n        assert result1 == 6\n        assert call_count == 1\n\n        result2 = process_tuple((1, 2, 3))\n        assert result2 == 6\n        assert call_count == 1  # Cache hit\n\n    def test_cache_with_boolean_arguments(self):\n        \"\"\"Test cache with boolean arguments.\"\"\"\n        call_count = 0\n\n        @cache\n        def process_bool(flag):\n            nonlocal call_count\n            call_count += 1\n            return \"yes\" if flag else \"no\"\n\n        result1 = process_bool(True)\n        assert result1 == \"yes\"\n        assert call_count == 1\n\n        result2 = process_bool(True)\n        assert result2 == \"yes\"\n        assert call_count == 1  # Cache hit\n\n        result3 = process_bool(False)\n        assert result3 == \"no\"\n        assert call_count == 2\n\n    def test_cache_global_state(self):\n        \"\"\"Test that cache is global across function calls.\"\"\"\n\n        @cache\n        def func(x):\n            return x * 2\n\n        func(5)\n        assert len(cache_module._cache) == 1\n\n        func(10)\n        assert len(cache_module._cache) == 2\n\n        func(5)  # Cache hit\n        assert len(cache_module._cache) == 2  # No new entry\n"
  },
  {
    "path": "backend/tests/test_callbacks.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom unittest.mock import AsyncMock, Mock\n\nfrom chainlit import config\nfrom chainlit.callbacks import password_auth_callback\nfrom chainlit.data.base import BaseDataLayer\nfrom chainlit.types import ThreadDict\nfrom chainlit.user import User\n\n\nasync def test_password_auth_callback(test_config: config.ChainlitConfig):\n    @password_auth_callback\n    async def auth_func(username: str, password: str) -> User | None:\n        if username == \"testuser\" and password == \"testpass\":  # nosec B105\n            return User(identifier=\"testuser\")\n        return None\n\n    # Test that the callback is properly registered\n    assert test_config.code.password_auth_callback is not None\n\n    # Test the wrapped function\n    result = await test_config.code.password_auth_callback(\"testuser\", \"testpass\")\n    assert isinstance(result, User)\n    assert result.identifier == \"testuser\"\n\n    # Test with incorrect credentials\n    result = await test_config.code.password_auth_callback(\"wronguser\", \"wrongpass\")\n    assert result is None\n\n\nasync def test_header_auth_callback(test_config: config.ChainlitConfig):\n    from starlette.datastructures import Headers\n\n    from chainlit.callbacks import header_auth_callback\n\n    @header_auth_callback\n    async def auth_func(headers: Headers) -> User | None:\n        if headers.get(\"Authorization\") == \"Bearer valid_token\":\n            return User(identifier=\"testuser\")\n        return None\n\n    # Test that the callback is properly registered\n    assert test_config.code.header_auth_callback is not None\n\n    # Test the wrapped function with valid header\n    valid_headers = Headers({\"Authorization\": \"Bearer valid_token\"})\n    result = await test_config.code.header_auth_callback(valid_headers)\n    assert isinstance(result, User)\n    assert result.identifier == \"testuser\"\n\n    # Test with invalid header\n    invalid_headers = Headers({\"Authorization\": \"Bearer invalid_token\"})\n    result = await test_config.code.header_auth_callback(invalid_headers)\n    assert result is None\n\n    # Test with missing header\n    missing_headers = Headers({})\n    result = await test_config.code.header_auth_callback(missing_headers)\n    assert result is None\n\n\nasync def test_oauth_callback(test_config: config.ChainlitConfig):\n    from unittest.mock import patch\n\n    from chainlit.callbacks import oauth_callback\n    from chainlit.user import User\n\n    # Mock the get_configured_oauth_providers function\n    with patch(\n        \"chainlit.callbacks.get_configured_oauth_providers\", return_value=[\"google\"]\n    ):\n\n        @oauth_callback\n        async def auth_func(\n            provider_id: str,\n            token: str,\n            raw_user_data: dict,\n            default_app_user: User,\n            id_token: str | None = None,\n        ) -> User | None:\n            if provider_id == \"google\" and token == \"valid_token\":  # nosec B105\n                return User(identifier=\"oauth_user\")\n            return None\n\n        # Test that the callback is properly registered\n        assert test_config.code.oauth_callback is not None\n\n        # Test the wrapped function with valid data\n        result = await test_config.code.oauth_callback(\n            \"google\", \"valid_token\", {}, User(identifier=\"default_user\")\n        )\n        assert isinstance(result, User)\n        assert result.identifier == \"oauth_user\"\n\n        # Test with invalid data\n        result = await test_config.code.oauth_callback(\n            \"facebook\", \"invalid_token\", {}, User(identifier=\"default_user\")\n        )\n        assert result is None\n\n\nasync def test_on_message(mock_chainlit_context, test_config: config.ChainlitConfig):\n    from chainlit.callbacks import on_message\n    from chainlit.message import Message\n\n    async with mock_chainlit_context:\n        message_received = None\n\n        @on_message\n        async def handle_message(message: Message):\n            nonlocal message_received\n            message_received = message\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_message is not None\n\n        # Create a test message\n        test_message = Message(content=\"Test message\", author=\"User\")\n\n        # Call the registered callback\n        await test_config.code.on_message(test_message)\n\n        # Check that the message was received by our handler\n        assert message_received is not None\n        assert message_received.content == \"Test message\"\n        assert message_received.author == \"User\"\n\n\nasync def test_on_stop(mock_chainlit_context, test_config: config.ChainlitConfig):\n    from chainlit.callbacks import on_stop\n\n    async with mock_chainlit_context:\n        stop_called = False\n\n        @on_stop\n        async def handle_stop():\n            nonlocal stop_called\n            stop_called = True\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_stop is not None\n\n        # Call the registered callback\n        await test_config.code.on_stop()\n\n        # Check that the stop_called flag was set\n        assert stop_called\n\n\nasync def test_action_callback(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.action import Action\n    from chainlit.callbacks import action_callback\n\n    async with mock_chainlit_context:\n        action_handled = False\n\n        @action_callback(\"test_action\")\n        async def handle_action(action: Action):\n            nonlocal action_handled\n            action_handled = True\n            assert action.name == \"test_action\"\n\n        # Test that the callback is properly registered\n        assert \"test_action\" in test_config.code.action_callbacks\n\n        # Call the registered callback\n        test_action = Action(name=\"test_action\", payload={\"value\": \"test_value\"})\n        await test_config.code.action_callbacks[\"test_action\"](test_action)\n\n        # Check that the action_handled flag was set\n        assert action_handled\n\n\nasync def test_on_settings_update(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import on_settings_update\n\n    async with mock_chainlit_context:\n        settings_updated = False\n\n        @on_settings_update\n        async def handle_settings_update(settings: dict):\n            nonlocal settings_updated\n            settings_updated = True\n            assert settings == {\"test_setting\": \"test_value\"}\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_settings_update is not None\n\n        # Call the registered callback\n        await test_config.code.on_settings_update({\"test_setting\": \"test_value\"})\n\n        # Check that the settings_updated flag was set\n        assert settings_updated\n\n\nasync def test_author_rename(test_config: config.ChainlitConfig):\n    from chainlit.callbacks import author_rename\n\n    @author_rename\n    async def rename_author(author: str) -> str:\n        if author == \"AI\":\n            return \"Assistant\"\n        return author\n\n    # Test that the callback is properly registered\n    assert test_config.code.author_rename is not None\n\n    # Call the registered callback\n    result = await test_config.code.author_rename(\"AI\")\n    assert result == \"Assistant\"\n\n    result = await test_config.code.author_rename(\"Human\")\n    assert result == \"Human\"\n\n    # Test that the callback is properly registered\n    assert test_config.code.author_rename is not None\n\n    # Call the registered callback\n    result = await test_config.code.author_rename(\"AI\")\n    assert result == \"Assistant\"\n\n    result = await test_config.code.author_rename(\"Human\")\n    assert result == \"Human\"\n\n\nasync def test_on_app_startup(test_config: config.ChainlitConfig):\n    \"\"\"Test the on_app_startup callback registration and execution for sync and async functions.\"\"\"\n    from chainlit.callbacks import on_app_startup\n\n    # Test with synchronous function\n    sync_startup_called = False\n\n    @on_app_startup\n    def sync_startup():\n        nonlocal sync_startup_called\n        sync_startup_called = True\n\n    assert test_config.code.on_app_startup is not None, (\n        \"Sync startup callback not registered\"\n    )\n    # Call the wrapped function (which might be async due to wrap_user_function)\n    result = test_config.code.on_app_startup()\n    if asyncio.iscoroutine(result):\n        await result\n    assert sync_startup_called, \"Sync startup function was not called\"\n\n    # Reset for async test\n    test_config.code.on_app_startup = None  # Explicitly clear previous registration\n\n    # Test with asynchronous function\n    async_startup_called = False\n\n    @on_app_startup\n    async def async_startup():\n        nonlocal async_startup_called\n        await asyncio.sleep(0)  # Simulate async work\n        async_startup_called = True\n\n    assert test_config.code.on_app_startup is not None, (\n        \"Async startup callback not registered\"\n    )\n    # Call the wrapped function (which should be async)\n    result = test_config.code.on_app_startup()\n    assert asyncio.iscoroutine(result), (\n        \"Async startup function did not return a coroutine\"\n    )\n    await result\n    assert async_startup_called, \"Async startup function was not called\"\n\n\nasync def test_on_app_shutdown(test_config: config.ChainlitConfig):\n    \"\"\"Test the on_app_shutdown callback registration and execution for sync and async functions.\"\"\"\n    from chainlit.callbacks import on_app_shutdown\n\n    # Test with synchronous function\n    sync_shutdown_called = False\n\n    @on_app_shutdown\n    def sync_shutdown():\n        nonlocal sync_shutdown_called\n        sync_shutdown_called = True\n\n    assert test_config.code.on_app_shutdown is not None, (\n        \"Sync shutdown callback not registered\"\n    )\n    # Call the wrapped function\n    result = test_config.code.on_app_shutdown()\n    if asyncio.iscoroutine(result):\n        await result\n    assert sync_shutdown_called, \"Sync shutdown function was not called\"\n\n    # Reset for async test\n    test_config.code.on_app_shutdown = None  # Explicitly clear previous registration\n\n    # Test with asynchronous function\n    async_shutdown_called = False\n\n    @on_app_shutdown\n    async def async_shutdown():\n        nonlocal async_shutdown_called\n        await asyncio.sleep(0)  # Simulate async work\n        async_shutdown_called = True\n\n    assert test_config.code.on_app_shutdown is not None, (\n        \"Async shutdown callback not registered\"\n    )\n    # Call the wrapped function\n    result = test_config.code.on_app_shutdown()\n    assert asyncio.iscoroutine(result), (\n        \"Async shutdown function did not return a coroutine\"\n    )\n    await result\n    assert async_shutdown_called, \"Async shutdown function was not called\"\n\n\nasync def test_on_chat_start(mock_chainlit_context, test_config: config.ChainlitConfig):\n    from chainlit.callbacks import on_chat_start\n\n    async with mock_chainlit_context:\n        chat_started = False\n\n        @on_chat_start\n        async def handle_chat_start():\n            nonlocal chat_started\n            chat_started = True\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_chat_start is not None\n\n        # Call the registered callback\n        await test_config.code.on_chat_start()\n\n        # Check that the chat_started flag was set\n        assert chat_started\n\n\nasync def test_on_chat_resume(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import on_chat_resume\n\n    async with mock_chainlit_context:\n        chat_resumed = False\n\n        @on_chat_resume\n        async def handle_chat_resume(thread: ThreadDict):\n            nonlocal chat_resumed\n            chat_resumed = True\n            assert thread[\"id\"] == \"test_thread_id\"\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_chat_resume is not None\n\n        # Call the registered callback\n        await test_config.code.on_chat_resume(\n            {\n                \"id\": \"test_thread_id\",\n                \"createdAt\": \"2023-01-01T00:00:00Z\",\n                \"name\": \"Test Thread\",\n                \"userId\": \"test_user_id\",\n                \"userIdentifier\": \"test_user\",\n                \"tags\": [],\n                \"metadata\": {},\n                \"steps\": [],\n                \"elements\": [],\n            }\n        )\n\n        # Check that the chat_resumed flag was set\n        assert chat_resumed\n\n\nasync def test_set_chat_profiles(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import set_chat_profiles\n    from chainlit.types import ChatProfile\n\n    async with mock_chainlit_context:\n\n        @set_chat_profiles\n        async def get_chat_profiles(user, language):\n            return [\n                ChatProfile(name=\"Test Profile\", markdown_description=\"A test profile\")\n            ]\n\n        # Test that the callback is properly registered\n        assert test_config.code.set_chat_profiles is not None\n\n        # Call the registered callback\n        result = await test_config.code.set_chat_profiles(None, None)\n\n        # Check the result\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 1\n        assert isinstance(result[0], ChatProfile)\n        assert result[0].name == \"Test Profile\"\n        assert result[0].markdown_description == \"A test profile\"\n\n\nasync def test_set_chat_profiles_language(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import set_chat_profiles\n    from chainlit.types import ChatProfile\n\n    async with mock_chainlit_context:\n\n        @set_chat_profiles\n        async def get_chat_profiles(user, language):\n            if language == \"fr-CA\":\n                return [\n                    ChatProfile(\n                        name=\"Profil de test\", markdown_description=\"Un profil de test\"\n                    )\n                ]\n\n            return [\n                ChatProfile(name=\"Test Profile\", markdown_description=\"A test profile\")\n            ]\n\n        # Test that the callback is properly registered\n        assert test_config.code.set_chat_profiles is not None\n\n        # Call the registered callback\n        result = await test_config.code.set_chat_profiles(None, \"fr-CA\")\n\n        # Check the result\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 1\n        assert isinstance(result[0], ChatProfile)\n        assert result[0].name == \"Profil de test\"\n        assert result[0].markdown_description == \"Un profil de test\"\n\n\nasync def test_set_starters(mock_chainlit_context, test_config: config.ChainlitConfig):\n    from chainlit.callbacks import set_starters\n    from chainlit.types import Starter\n\n    async with mock_chainlit_context:\n\n        @set_starters\n        async def get_starters(user):\n            return [\n                Starter(\n                    label=\"Test Label\",\n                    message=\"Test Message\",\n                )\n            ]\n\n        # Test that the callback is properly registered\n        assert test_config.code.set_starters is not None\n\n        # Call the registered callback\n        result = await test_config.code.set_starters(None, None)\n\n        # Check the result\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 1\n        assert isinstance(result[0], Starter)\n        assert result[0].label == \"Test Label\"\n        assert result[0].message == \"Test Message\"\n\n\nasync def test_set_starters_language(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import set_starters\n    from chainlit.types import Starter\n\n    async with mock_chainlit_context:\n\n        @set_starters\n        async def get_starters(user, language):\n            if language == \"fr-CA\":\n                return [\n                    Starter(\n                        label=\"Étiquette de test\",\n                        message=\"Message de test\",\n                    )\n                ]\n\n            return [\n                Starter(\n                    label=\"Test Label\",\n                    message=\"Test Message\",\n                )\n            ]\n\n        # Test that the callback is properly registered\n        assert test_config.code.set_starters is not None\n\n        # Call the registered callback\n        result = await test_config.code.set_starters(None, \"fr-CA\")\n\n        # Check the result\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 1\n        assert isinstance(result[0], Starter)\n        assert result[0].label == \"Étiquette de test\"\n        assert result[0].message == \"Message de test\"\n\n\nasync def test_set_starter_categories(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import set_starter_categories\n    from chainlit.types import Starter, StarterCategory\n\n    async with mock_chainlit_context:\n\n        @set_starter_categories\n        async def get_starter_categories(user, language):\n            return [\n                StarterCategory(\n                    label=\"Creative\",\n                    icon=\"https://example.com/creative.png\",\n                    starters=[\n                        Starter(label=\"Write a poem\", message=\"Write a poem\"),\n                        Starter(label=\"Write a story\", message=\"Write a story\"),\n                    ],\n                ),\n                StarterCategory(\n                    label=\"Educational\",\n                    starters=[\n                        Starter(label=\"Explain concept\", message=\"Explain it\"),\n                    ],\n                ),\n            ]\n\n        assert test_config.code.set_starter_categories is not None\n\n        result = await test_config.code.set_starter_categories(None, None)\n\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 2\n\n        assert result[0].label == \"Creative\"\n        assert result[0].icon == \"https://example.com/creative.png\"\n        assert len(result[0].starters) == 2\n        assert result[0].starters[0].label == \"Write a poem\"\n\n        assert result[1].label == \"Educational\"\n        assert result[1].icon is None\n        assert len(result[1].starters) == 1\n\n        category_dict = result[0].to_dict()\n        assert category_dict[\"label\"] == \"Creative\"\n        assert category_dict[\"icon\"] == \"https://example.com/creative.png\"\n        starters_list = category_dict[\"starters\"]\n        assert isinstance(starters_list, list)\n        assert len(starters_list) == 2\n\n\nasync def test_on_shared_thread_view_allow(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import on_shared_thread_view\n    from chainlit.user import User\n\n    async with mock_chainlit_context:\n        # Simulate a viewer with access to certain chat profiles\n        allowed_profiles_by_user = {\"viewer\": {\"pro\", \"basic\"}}\n\n        @on_shared_thread_view\n        async def allow_shared_view(thread, viewer: User | None):\n            md = thread.get(\"metadata\") or {}\n            chat_profile = (md or {}).get(\"chat_profile\")\n            if not md.get(\"is_shared\"):\n                return False\n            if not viewer:\n                return False\n            return chat_profile in allowed_profiles_by_user.get(\n                viewer.identifier, set()\n            )\n\n        assert test_config.code.on_shared_thread_view is not None\n\n        thread: ThreadDict = {\n            \"id\": \"t1\",\n            \"createdAt\": \"2025-09-03T00:00:00Z\",\n            \"name\": \"Shared Thread\",\n            \"userId\": \"author_id\",\n            \"userIdentifier\": \"author\",\n            \"tags\": [],\n            \"metadata\": {\"is_shared\": True, \"chat_profile\": \"pro\"},\n            \"steps\": [],\n            \"elements\": [],\n        }\n        viewer = User(identifier=\"viewer\")\n\n        res = await test_config.code.on_shared_thread_view(thread, viewer)\n        assert res is True\n\n\nasync def test_on_shared_thread_view_block_and_exception(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    from chainlit.callbacks import on_shared_thread_view\n    from chainlit.user import User\n\n    async with mock_chainlit_context:\n        # Case 1: Explicitly return False when profile not allowed\n        @on_shared_thread_view\n        async def deny_when_not_allowed(thread, viewer: User | None):\n            md = thread.get(\"metadata\") or {}\n            return md.get(\"chat_profile\") == \"allowed\"\n\n        assert test_config.code.on_shared_thread_view is not None\n\n        thread: ThreadDict = {\n            \"id\": \"t2\",\n            \"createdAt\": \"2025-09-03T00:00:00Z\",\n            \"name\": \"Shared Thread\",\n            \"userId\": \"author_id\",\n            \"userIdentifier\": \"author\",\n            \"tags\": [],\n            \"metadata\": {\"is_shared\": True, \"chat_profile\": \"restricted\"},\n            \"steps\": [],\n            \"elements\": [],\n        }\n        viewer = User(identifier=\"viewer\")\n        res = await test_config.code.on_shared_thread_view(thread, viewer)\n        assert not res\n\n        # Case 2: Raise an exception inside callback; wrapper should swallow and result should be falsy\n        @on_shared_thread_view\n        async def raise_on_forbidden(thread, viewer: User | None):\n            md = thread.get(\"metadata\") or {}\n            if md.get(\"chat_profile\") == \"forbidden\":\n                raise ValueError(\"Viewer not allowed for this profile\")\n            return True\n\n        assert test_config.code.on_shared_thread_view is not None\n\n        thread_err: ThreadDict = {\n            \"id\": \"t3\",\n            \"createdAt\": \"2025-09-03T00:00:00Z\",\n            \"name\": \"Shared Thread\",\n            \"userId\": \"author_id\",\n            \"userIdentifier\": \"author\",\n            \"tags\": [],\n            \"metadata\": {\"is_shared\": True, \"chat_profile\": \"forbidden\"},\n            \"steps\": [],\n            \"elements\": [],\n        }\n        res2 = await test_config.code.on_shared_thread_view(thread_err, viewer)\n        assert not res2\n\n\nasync def test_on_chat_end(mock_chainlit_context, test_config: config.ChainlitConfig):\n    from chainlit.callbacks import on_chat_end\n\n    async with mock_chainlit_context:\n        chat_ended = False\n\n        @on_chat_end\n        async def handle_chat_end():\n            nonlocal chat_ended\n            chat_ended = True\n\n        # Test that the callback is properly registered\n        assert test_config.code.on_chat_end is not None\n\n        # Call the registered callback\n        await test_config.code.on_chat_end()\n\n        # Check that the chat_ended flag was set\n        assert chat_ended\n\n\ndef test_data_layer_config(\n    mock_data_layer: AsyncMock,\n    test_config: config.ChainlitConfig,\n    mock_get_data_layer: Mock,\n):\n    \"\"\"Test whether we can properly configure a data layer.\"\"\"\n\n    # Test that the callback is properly registered\n    assert test_config.code.data_layer is not None\n\n    # Call the registered callback\n    result = test_config.code.data_layer()\n\n    # Check that the result is an instance of MockDataLayer\n    assert isinstance(result, BaseDataLayer)\n\n    mock_get_data_layer.assert_called_once()\n\n\ndef test_chat_profile_with_config_overrides():\n    \"\"\"Test that ChatProfile can be created with config_overrides.\"\"\"\n    from chainlit.config import (\n        ChainlitConfigOverrides,\n        FeaturesSettings,\n        McpFeature,\n        UISettings,\n    )\n    from chainlit.types import ChatProfile\n\n    # Test creating a profile without config_overrides\n    basic_profile = ChatProfile(\n        name=\"Basic Profile\", markdown_description=\"A basic profile without overrides\"\n    )\n    assert basic_profile.config_overrides is None\n\n    # Test creating a profile with config_overrides\n    config_overrides = ChainlitConfigOverrides(\n        features=FeaturesSettings(mcp=McpFeature(enabled=True)),\n        ui=UISettings(\n            name=\"Custom App Name\",\n            description=\"Custom description\",\n            default_theme=\"light\",\n        ),\n    )\n\n    profile_with_overrides = ChatProfile(\n        name=\"MCP Profile\",\n        markdown_description=\"A profile with MCP enabled\",\n        config_overrides=config_overrides,\n    )\n\n    # Verify the profile was created successfully\n    assert profile_with_overrides.name == \"MCP Profile\"\n    assert profile_with_overrides.config_overrides is not None\n    assert profile_with_overrides.config_overrides.features.mcp.enabled is True\n    assert profile_with_overrides.config_overrides.ui.name == \"Custom App Name\"\n    assert profile_with_overrides.config_overrides.ui.default_theme == \"light\"\n\n\nasync def test_set_chat_profiles_with_config_overrides(\n    mock_chainlit_context, test_config: config.ChainlitConfig\n):\n    \"\"\"Test that set_chat_profiles callback works with profiles that have config_overrides.\"\"\"\n    from chainlit.callbacks import set_chat_profiles\n    from chainlit.config import (\n        ChainlitConfigOverrides,\n        FeaturesSettings,\n        McpFeature,\n        UISettings,\n    )\n    from chainlit.types import ChatProfile\n\n    async with mock_chainlit_context:\n\n        @set_chat_profiles\n        async def get_chat_profiles(user, language):\n            return [\n                ChatProfile(\n                    name=\"Basic Profile\",\n                    markdown_description=\"A basic profile without overrides\",\n                ),\n                ChatProfile(\n                    name=\"MCP Profile\",\n                    markdown_description=\"A profile with MCP enabled\",\n                    config_overrides=ChainlitConfigOverrides(\n                        features=FeaturesSettings(mcp=McpFeature(enabled=True)),\n                        ui=UISettings(name=\"MCP Assistant\", default_theme=\"dark\"),\n                    ),\n                ),\n                ChatProfile(\n                    name=\"Light Theme Profile\",\n                    markdown_description=\"A profile with light theme\",\n                    config_overrides=ChainlitConfigOverrides(\n                        ui=UISettings(name=\"Light Theme App\", default_theme=\"light\")\n                    ),\n                ),\n            ]\n\n        # Test that the callback is properly registered\n        assert test_config.code.set_chat_profiles is not None\n\n        # Call the registered callback\n        result = await test_config.code.set_chat_profiles(None, None)\n\n        # Check the result\n        assert result is not None\n        assert isinstance(result, list)\n        assert len(result) == 3\n\n        # Test basic profile\n        basic_profile = result[0]\n        assert basic_profile.name == \"Basic Profile\"\n        assert basic_profile.config_overrides is None\n\n        # Test MCP profile\n        mcp_profile = result[1]\n        assert mcp_profile.name == \"MCP Profile\"\n        assert mcp_profile.config_overrides is not None\n        assert mcp_profile.config_overrides.features.mcp.enabled is True\n        assert mcp_profile.config_overrides.ui.name == \"MCP Assistant\"\n        assert mcp_profile.config_overrides.ui.default_theme == \"dark\"\n\n        # Test light theme profile\n        light_profile = result[2]\n        assert light_profile.name == \"Light Theme Profile\"\n        assert light_profile.config_overrides is not None\n        assert light_profile.config_overrides.ui.name == \"Light Theme App\"\n        assert light_profile.config_overrides.ui.default_theme == \"light\"\n"
  },
  {
    "path": "backend/tests/test_chat_context.py",
    "content": "import asyncio\nfrom contextlib import contextmanager\nfrom unittest.mock import Mock, patch\n\nfrom chainlit.chat_context import chat_context, chat_contexts\nfrom chainlit.context import ChainlitContext, context_var\n\n\n@contextmanager\ndef mock_chainlit_context(session=None):\n    \"\"\"Context manager to set up and tear down Chainlit context.\"\"\"\n    # Mock the event loop since we're not in an async context\n    mock_loop = Mock(spec=asyncio.AbstractEventLoop)\n\n    with patch(\"asyncio.get_running_loop\", return_value=mock_loop):\n        mock_context = ChainlitContext(session=session)\n        token = context_var.set(mock_context)\n        try:\n            yield mock_context\n        finally:\n            context_var.reset(token)\n\n\nclass TestChatContext:\n    \"\"\"Test suite for ChatContext class.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Clear chat_contexts before each test.\"\"\"\n        chat_contexts.clear()\n\n    def teardown_method(self):\n        \"\"\"Clear chat_contexts after each test.\"\"\"\n        chat_contexts.clear()\n\n    def test_get_without_session(self):\n        \"\"\"Test get returns empty list when no session exists.\"\"\"\n        with mock_chainlit_context(session=None):\n            result = chat_context.get()\n            assert result == []\n\n    def test_get_with_new_session(self):\n        \"\"\"Test get creates new chat context for new session.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.get()\n\n            assert result == []\n            assert \"session_123\" in chat_contexts\n            assert chat_contexts[\"session_123\"] == []\n\n    def test_get_returns_copy(self):\n        \"\"\"Test get returns a copy of the chat context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_message = Mock()\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.get()\n\n            assert result == [mock_message]\n            # Verify it's a copy, not the original\n            assert result is not chat_contexts[\"session_123\"]\n\n    def test_get_with_existing_messages(self):\n        \"\"\"Test get returns existing messages.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n        chat_contexts[\"session_123\"] = [mock_msg1, mock_msg2]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.get()\n\n            assert len(result) == 2\n            assert mock_msg1 in result\n            assert mock_msg2 in result\n\n    def test_add_without_session(self):\n        \"\"\"Test add does nothing when no session exists.\"\"\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=None):\n            result = chat_context.add(mock_message)\n\n            assert result is None\n            assert len(chat_contexts) == 0\n\n    def test_add_with_new_session(self):\n        \"\"\"Test add creates new chat context and adds message.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.add(mock_message)\n\n            assert result == mock_message\n            assert \"session_123\" in chat_contexts\n            assert mock_message in chat_contexts[\"session_123\"]\n\n    def test_add_message_to_existing_context(self):\n        \"\"\"Test add appends message to existing context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n        chat_contexts[\"session_123\"] = [mock_msg1]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.add(mock_msg2)\n\n            assert result == mock_msg2\n            assert len(chat_contexts[\"session_123\"]) == 2\n            assert mock_msg1 in chat_contexts[\"session_123\"]\n            assert mock_msg2 in chat_contexts[\"session_123\"]\n\n    def test_add_duplicate_message(self):\n        \"\"\"Test add does not add duplicate messages.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n        mock_message = Mock()\n\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.add(mock_message)\n\n            assert result == mock_message\n            assert len(chat_contexts[\"session_123\"]) == 1\n\n    def test_remove_without_session(self):\n        \"\"\"Test remove returns False when no session exists.\"\"\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=None):\n            result = chat_context.remove(mock_message)\n\n            assert result is False\n\n    def test_remove_with_nonexistent_context(self):\n        \"\"\"Test remove returns False when context doesn't exist.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.remove(mock_message)\n\n            assert result is False\n\n    def test_remove_nonexistent_message(self):\n        \"\"\"Test remove returns False when message not in context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n        chat_contexts[\"session_123\"] = [mock_msg1]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.remove(mock_msg2)\n\n            assert result is False\n            assert mock_msg1 in chat_contexts[\"session_123\"]\n\n    def test_remove_existing_message(self):\n        \"\"\"Test remove successfully removes message.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n        chat_contexts[\"session_123\"] = [mock_msg1, mock_msg2]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.remove(mock_msg1)\n\n            assert result is True\n            assert mock_msg1 not in chat_contexts[\"session_123\"]\n            assert mock_msg2 in chat_contexts[\"session_123\"]\n            assert len(chat_contexts[\"session_123\"]) == 1\n\n    def test_clear_without_session(self):\n        \"\"\"Test clear does nothing when no session exists.\"\"\"\n        chat_contexts[\"session_123\"] = [Mock()]\n\n        with mock_chainlit_context(session=None):\n            chat_context.clear()\n\n            # Original context should remain\n            assert \"session_123\" in chat_contexts\n\n    def test_clear_with_nonexistent_context(self):\n        \"\"\"Test clear does nothing when context doesn't exist.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_456\"\n\n        chat_contexts[\"session_123\"] = [Mock()]\n\n        with mock_chainlit_context(session=mock_session):\n            chat_context.clear()\n\n            # Original context should remain\n            assert \"session_123\" in chat_contexts\n\n    def test_clear_existing_context(self):\n        \"\"\"Test clear empties existing context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n        chat_contexts[\"session_123\"] = [mock_msg1, mock_msg2]\n\n        with mock_chainlit_context(session=mock_session):\n            chat_context.clear()\n\n            assert \"session_123\" in chat_contexts\n            assert chat_contexts[\"session_123\"] == []\n\n    def test_to_openai_with_assistant_message(self):\n        \"\"\"Test to_openai converts assistant messages correctly.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_message = Mock()\n        mock_message.type = \"assistant_message\"\n        mock_message.content = \"Hello, how can I help?\"\n\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 1\n            assert result[0] == {\n                \"role\": \"assistant\",\n                \"content\": \"Hello, how can I help?\",\n            }\n\n    def test_to_openai_with_user_message(self):\n        \"\"\"Test to_openai converts user messages correctly.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_message = Mock()\n        mock_message.type = \"user_message\"\n        mock_message.content = \"What is the weather?\"\n\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 1\n            assert result[0] == {\"role\": \"user\", \"content\": \"What is the weather?\"}\n\n    def test_to_openai_with_system_message(self):\n        \"\"\"Test to_openai converts system messages correctly.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_message = Mock()\n        mock_message.type = \"system_message\"\n        mock_message.content = \"You are a helpful assistant.\"\n\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 1\n            assert result[0] == {\n                \"role\": \"system\",\n                \"content\": \"You are a helpful assistant.\",\n            }\n\n    def test_to_openai_with_unknown_message_type(self):\n        \"\"\"Test to_openai treats unknown types as system messages.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_message = Mock()\n        mock_message.type = \"unknown_type\"\n        mock_message.content = \"Unknown message\"\n\n        chat_contexts[\"session_123\"] = [mock_message]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 1\n            assert result[0] == {\"role\": \"system\", \"content\": \"Unknown message\"}\n\n    def test_to_openai_with_multiple_messages(self):\n        \"\"\"Test to_openai converts multiple messages correctly.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg1.type = \"user_message\"\n        mock_msg1.content = \"Hello\"\n\n        mock_msg2 = Mock()\n        mock_msg2.type = \"assistant_message\"\n        mock_msg2.content = \"Hi there!\"\n\n        mock_msg3 = Mock()\n        mock_msg3.type = \"user_message\"\n        mock_msg3.content = \"How are you?\"\n\n        chat_contexts[\"session_123\"] = [mock_msg1, mock_msg2, mock_msg3]\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 3\n            assert result[0] == {\"role\": \"user\", \"content\": \"Hello\"}\n            assert result[1] == {\"role\": \"assistant\", \"content\": \"Hi there!\"}\n            assert result[2] == {\"role\": \"user\", \"content\": \"How are you?\"}\n\n    def test_to_openai_with_empty_context(self):\n        \"\"\"Test to_openai returns empty list for empty context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        chat_contexts[\"session_123\"] = []\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert result == []\n\n    def test_to_openai_without_session(self):\n        \"\"\"Test to_openai returns empty list when no session exists.\"\"\"\n        with mock_chainlit_context(session=None):\n            result = chat_context.to_openai()\n\n            assert result == []\n\n\nclass TestChatContextEdgeCases:\n    \"\"\"Test suite for chat_context edge cases.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Clear chat_contexts before each test.\"\"\"\n        chat_contexts.clear()\n\n    def teardown_method(self):\n        \"\"\"Clear chat_contexts after each test.\"\"\"\n        chat_contexts.clear()\n\n    def test_multiple_sessions_isolated(self):\n        \"\"\"Test that different sessions have isolated contexts.\"\"\"\n        mock_session1 = Mock()\n        mock_session1.id = \"session_1\"\n\n        mock_session2 = Mock()\n        mock_session2.id = \"session_2\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n\n        with mock_chainlit_context(session=mock_session1):\n            chat_context.add(mock_msg1)\n\n        with mock_chainlit_context(session=mock_session2):\n            chat_context.add(mock_msg2)\n\n        assert len(chat_contexts) == 2\n        assert mock_msg1 in chat_contexts[\"session_1\"]\n        assert mock_msg2 in chat_contexts[\"session_2\"]\n        assert mock_msg1 not in chat_contexts[\"session_2\"]\n        assert mock_msg2 not in chat_contexts[\"session_1\"]\n\n    def test_add_then_remove_then_add_again(self):\n        \"\"\"Test adding, removing, and re-adding the same message.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=mock_session):\n            # Add\n            chat_context.add(mock_message)\n            assert len(chat_contexts[\"session_123\"]) == 1\n\n            # Remove\n            result = chat_context.remove(mock_message)\n            assert result is True\n            assert len(chat_contexts[\"session_123\"]) == 0\n\n            # Add again\n            chat_context.add(mock_message)\n            assert len(chat_contexts[\"session_123\"]) == 1\n\n    def test_clear_then_add(self):\n        \"\"\"Test adding messages after clearing context.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        mock_msg1 = Mock()\n        mock_msg2 = Mock()\n\n        with mock_chainlit_context(session=mock_session):\n            chat_context.add(mock_msg1)\n            chat_context.clear()\n            chat_context.add(mock_msg2)\n\n            result = chat_context.get()\n            assert len(result) == 1\n            assert mock_msg2 in result\n            assert mock_msg1 not in result\n\n    def test_to_openai_with_mixed_message_types(self):\n        \"\"\"Test to_openai with various message types in sequence.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n\n        messages = [\n            Mock(type=\"system_message\", content=\"System prompt\"),\n            Mock(type=\"user_message\", content=\"User query\"),\n            Mock(type=\"assistant_message\", content=\"Assistant response\"),\n            Mock(type=\"other_type\", content=\"Other message\"),\n        ]\n\n        chat_contexts[\"session_123\"] = messages\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.to_openai()\n\n            assert len(result) == 4\n            assert result[0][\"role\"] == \"system\"\n            assert result[1][\"role\"] == \"user\"\n            assert result[2][\"role\"] == \"assistant\"\n            assert result[3][\"role\"] == \"system\"  # Unknown types default to system\n\n    def test_chat_context_singleton(self):\n        \"\"\"Test that chat_context is a singleton instance.\"\"\"\n        from chainlit.chat_context import chat_context as imported_context\n\n        assert chat_context is imported_context\n\n    def test_add_returns_message(self):\n        \"\"\"Test that add returns the message for chaining.\"\"\"\n        mock_session = Mock()\n        mock_session.id = \"session_123\"\n        mock_message = Mock()\n\n        with mock_chainlit_context(session=mock_session):\n            result = chat_context.add(mock_message)\n\n            assert result is mock_message\n"
  },
  {
    "path": "backend/tests/test_chat_settings.py",
    "content": "import pytest\n\nfrom chainlit.chat_settings import ChatSettings\nfrom chainlit.input_widget import (\n    Checkbox,\n    NumberInput,\n    Select,\n    Slider,\n    Switch,\n    Tab,\n    TextInput,\n)\n\n\n@pytest.mark.asyncio\nclass TestChatSettings:\n    \"\"\"Test suite for ChatSettings class.\"\"\"\n\n    async def test_chat_settings_initialization(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings initialization with input widgets.\"\"\"\n        async with mock_chainlit_context:\n            switch = Switch(id=\"enable_feature\", label=\"Enable Feature\")\n            slider = Slider(id=\"temperature\", label=\"Temperature\", initial=0.7)\n\n            settings = ChatSettings(inputs=[switch, slider])\n\n            assert len(settings.inputs) == 2\n            assert settings.inputs[0] == switch\n            assert settings.inputs[1] == slider\n\n    async def test_chat_settings_with_empty_inputs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings with empty inputs list.\"\"\"\n        async with mock_chainlit_context:\n            settings = ChatSettings(inputs=[])\n\n            assert settings.inputs == []\n\n    async def test_chat_settings_settings_method(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings.settings() method returns initial values.\"\"\"\n        async with mock_chainlit_context:\n            switch = Switch(id=\"enable_feature\", label=\"Enable\", initial=True)\n            slider = Slider(id=\"temperature\", label=\"Temperature\", initial=0.7)\n            text = TextInput(id=\"model\", label=\"Model\", initial=\"gpt-4\")\n\n            settings = ChatSettings(inputs=[switch, slider, text])\n            result = settings.settings()\n\n            assert result[\"enable_feature\"] is True\n            assert result[\"temperature\"] == 0.7\n            assert result[\"model\"] == \"gpt-4\"\n\n    async def test_chat_settings_with_tabs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings with Tab containers.\"\"\"\n        async with mock_chainlit_context:\n            # Create inputs for tabs\n            switch1 = Switch(id=\"switch1\", label=\"Switch 1\", initial=True)\n            slider1 = Slider(id=\"slider1\", label=\"Slider 1\", initial=5)\n\n            switch2 = Switch(id=\"switch2\", label=\"Switch 2\", initial=False)\n            text2 = TextInput(id=\"text2\", label=\"Text 2\", initial=\"value\")\n\n            # Create tabs\n            tab1 = Tab(id=\"tab1\", label=\"Tab 1\", inputs=[switch1, slider1])\n            tab2 = Tab(id=\"tab2\", label=\"Tab 2\", inputs=[switch2, text2])\n\n            settings = ChatSettings(inputs=[tab1, tab2])\n\n            assert len(settings.inputs) == 2\n            assert isinstance(settings.inputs[0], Tab)\n            assert isinstance(settings.inputs[1], Tab)\n\n    async def test_chat_settings_settings_with_tabs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings.settings() collects values from tabs.\"\"\"\n        async with mock_chainlit_context:\n            switch1 = Switch(id=\"switch1\", label=\"Switch 1\", initial=True)\n            slider1 = Slider(id=\"slider1\", label=\"Slider 1\", initial=5)\n\n            switch2 = Switch(id=\"switch2\", label=\"Switch 2\", initial=False)\n            text2 = TextInput(id=\"text2\", label=\"Text 2\", initial=\"value\")\n\n            tab1 = Tab(id=\"tab1\", label=\"Tab 1\", inputs=[switch1, slider1])\n            tab2 = Tab(id=\"tab2\", label=\"Tab 2\", inputs=[switch2, text2])\n\n            settings = ChatSettings(inputs=[tab1, tab2])\n            result = settings.settings()\n\n            # Should collect all inputs from all tabs\n            assert result[\"switch1\"] is True\n            assert result[\"slider1\"] == 5\n            assert result[\"switch2\"] is False\n            assert result[\"text2\"] == \"value\"\n\n    async def test_chat_settings_send(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings.send() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            switch = Switch(id=\"enable\", label=\"Enable\", initial=True)\n            slider = Slider(id=\"temp\", label=\"Temperature\", initial=0.8)\n\n            settings = ChatSettings(inputs=[switch, slider])\n            result = await settings.send()\n\n            # Verify settings were returned\n            assert result[\"enable\"] is True\n            assert result[\"temp\"] == 0.8\n\n            # Verify emitter methods were called\n            ctx.emitter.set_chat_settings.assert_called_once_with(result)\n            ctx.emitter.emit.assert_called_once()\n\n            # Verify emit was called with correct arguments\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"chat_settings\"\n            assert len(call_args[0][1]) == 2  # Two inputs\n\n    async def test_chat_settings_send_with_tabs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings.send() with tabs.\"\"\"\n        async with mock_chainlit_context as ctx:\n            switch = Switch(id=\"switch1\", label=\"Switch\", initial=True)\n            slider = Slider(id=\"slider1\", label=\"Slider\", initial=5)\n\n            tab = Tab(id=\"tab1\", label=\"Settings\", inputs=[switch, slider])\n            settings = ChatSettings(inputs=[tab])\n            result = await settings.send()\n\n            # Verify settings collected from tab\n            assert result[\"switch1\"] is True\n            assert result[\"slider1\"] == 5\n\n            # Verify emitter was called\n            ctx.emitter.set_chat_settings.assert_called_once()\n            ctx.emitter.emit.assert_called_once()\n\n    async def test_chat_settings_with_all_widget_types(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings with all widget types.\"\"\"\n        async with mock_chainlit_context:\n            widgets = [\n                Switch(id=\"switch\", label=\"Switch\", initial=True),\n                Slider(id=\"slider\", label=\"Slider\", initial=5, min=0, max=10),\n                Select(\n                    id=\"select\",\n                    label=\"Select\",\n                    values=[\"a\", \"b\", \"c\"],\n                    initial_index=1,\n                ),\n                TextInput(id=\"text\", label=\"Text\", initial=\"hello\"),\n                NumberInput(id=\"number\", label=\"Number\", initial=42.0),\n                Checkbox(id=\"checkbox\", label=\"Checkbox\", initial=False),\n            ]\n\n            settings = ChatSettings(inputs=widgets)\n            result = settings.settings()\n\n            assert result[\"switch\"] is True\n            assert result[\"slider\"] == 5\n            assert result[\"select\"] == \"b\"\n            assert result[\"text\"] == \"hello\"\n            assert result[\"number\"] == 42.0\n            assert result[\"checkbox\"] is False\n\n    async def test_chat_settings_with_nested_tabs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings.settings() with nested structure.\"\"\"\n        async with mock_chainlit_context:\n            # Create multiple tabs with different inputs\n            tab1_inputs = [\n                Switch(id=\"t1_switch\", label=\"T1 Switch\", initial=True),\n                Slider(id=\"t1_slider\", label=\"T1 Slider\", initial=3),\n            ]\n\n            tab2_inputs = [\n                TextInput(id=\"t2_text\", label=\"T2 Text\", initial=\"test\"),\n                Checkbox(id=\"t2_check\", label=\"T2 Check\", initial=True),\n            ]\n\n            tab1 = Tab(id=\"tab1\", label=\"Tab 1\", inputs=tab1_inputs)\n            tab2 = Tab(id=\"tab2\", label=\"Tab 2\", inputs=tab2_inputs)\n\n            settings = ChatSettings(inputs=[tab1, tab2])\n            result = settings.settings()\n\n            # All inputs from all tabs should be collected\n            assert result[\"t1_switch\"] is True\n            assert result[\"t1_slider\"] == 3\n            assert result[\"t2_text\"] == \"test\"\n            assert result[\"t2_check\"] is True\n            assert len(result) == 4\n\n    async def test_chat_settings_only_widgets_or_only_tabs(self, mock_chainlit_context):\n        \"\"\"Test that ChatSettings accepts either all widgets or all tabs, not mixed.\"\"\"\n        async with mock_chainlit_context:\n            # Test with only widgets\n            widgets = [\n                Switch(id=\"switch\", label=\"Switch\", initial=True),\n                Slider(id=\"slider\", label=\"Slider\", initial=7),\n            ]\n            settings_widgets = ChatSettings(inputs=widgets)\n            result_widgets = settings_widgets.settings()\n            assert result_widgets[\"switch\"] is True\n            assert result_widgets[\"slider\"] == 7\n\n            # Test with only tabs\n            tab1 = Tab(\n                id=\"tab1\",\n                label=\"Tab 1\",\n                inputs=[Switch(id=\"t1_switch\", label=\"Switch\", initial=False)],\n            )\n            tab2 = Tab(\n                id=\"tab2\",\n                label=\"Tab 2\",\n                inputs=[Slider(id=\"t2_slider\", label=\"Slider\", initial=3)],\n            )\n            settings_tabs = ChatSettings(inputs=[tab1, tab2])\n            result_tabs = settings_tabs.settings()\n            assert result_tabs[\"t1_switch\"] is False\n            assert result_tabs[\"t2_slider\"] == 3\n\n    async def test_chat_settings_with_none_initial_values(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings with widgets having None initial values.\"\"\"\n        async with mock_chainlit_context:\n            text = TextInput(id=\"text\", label=\"Text\", initial=None)\n            number = NumberInput(id=\"number\", label=\"Number\", initial=None)\n            select = Select(\n                id=\"select\", label=\"Select\", values=[\"a\", \"b\"], initial_value=None\n            )\n\n            settings = ChatSettings(inputs=[text, number, select])\n            result = settings.settings()\n\n            assert result[\"text\"] is None\n            assert result[\"number\"] is None\n            assert result[\"select\"] is None\n\n\n@pytest.mark.asyncio\nclass TestChatSettingsEdgeCases:\n    \"\"\"Test suite for ChatSettings edge cases.\"\"\"\n\n    async def test_chat_settings_empty_tabs(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings with empty tabs.\"\"\"\n        async with mock_chainlit_context:\n            empty_tab = Tab(id=\"empty\", label=\"Empty Tab\", inputs=[])\n            settings = ChatSettings(inputs=[empty_tab])\n            result = settings.settings()\n\n            assert result == {}\n\n    async def test_chat_settings_duplicate_ids(self, mock_chainlit_context):\n        \"\"\"Test ChatSettings behavior with duplicate IDs (last one wins).\"\"\"\n        async with mock_chainlit_context:\n            switch1 = Switch(id=\"duplicate\", label=\"Switch 1\", initial=True)\n            switch2 = Switch(id=\"duplicate\", label=\"Switch 2\", initial=False)\n\n            settings = ChatSettings(inputs=[switch1, switch2])\n            result = settings.settings()\n\n            # Last value should win\n            assert result[\"duplicate\"] is False\n\n    async def test_chat_settings_send_returns_settings(self, mock_chainlit_context):\n        \"\"\"Test that send() returns the settings dictionary.\"\"\"\n        async with mock_chainlit_context:\n            switch = Switch(id=\"test\", label=\"Test\", initial=True)\n            settings = ChatSettings(inputs=[switch])\n\n            result = await settings.send()\n\n            assert isinstance(result, dict)\n            assert \"test\" in result\n            assert result[\"test\"] is True\n\n    async def test_chat_settings_to_dict_serialization(self, mock_chainlit_context):\n        \"\"\"Test that inputs are properly serialized in send().\"\"\"\n        async with mock_chainlit_context as ctx:\n            switch = Switch(id=\"switch\", label=\"Switch\", initial=True)\n            slider = Slider(id=\"slider\", label=\"Slider\", initial=5)\n\n            settings = ChatSettings(inputs=[switch, slider])\n            await settings.send()\n\n            # Check that emit was called with serialized inputs\n            call_args = ctx.emitter.emit.call_args\n            inputs_content = call_args[0][1]\n\n            assert len(inputs_content) == 2\n            assert inputs_content[0][\"type\"] == \"switch\"\n            assert inputs_content[0][\"id\"] == \"switch\"\n            assert inputs_content[1][\"type\"] == \"slider\"\n            assert inputs_content[1][\"id\"] == \"slider\"\n\n    async def test_chat_settings_with_complex_tab_structure(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test ChatSettings with complex tab structure.\"\"\"\n        async with mock_chainlit_context:\n            # Create a complex structure with multiple tabs\n            tab1 = Tab(\n                id=\"general\",\n                label=\"General\",\n                inputs=[\n                    Switch(id=\"enabled\", label=\"Enabled\", initial=True),\n                    TextInput(id=\"name\", label=\"Name\", initial=\"MyApp\"),\n                ],\n            )\n\n            tab2 = Tab(\n                id=\"advanced\",\n                label=\"Advanced\",\n                inputs=[\n                    Slider(id=\"timeout\", label=\"Timeout\", initial=30, min=0, max=60),\n                    Select(\n                        id=\"mode\",\n                        label=\"Mode\",\n                        values=[\"dev\", \"prod\"],\n                        initial_index=0,\n                    ),\n                ],\n            )\n\n            settings = ChatSettings(inputs=[tab1, tab2])\n            result = settings.settings()\n\n            assert result[\"enabled\"] is True\n            assert result[\"name\"] == \"MyApp\"\n            assert result[\"timeout\"] == 30\n            assert result[\"mode\"] == \"dev\"\n            assert len(result) == 4\n"
  },
  {
    "path": "backend/tests/test_context.py",
    "content": "from unittest.mock import Mock\n\nimport pytest\n\nfrom chainlit.context import (\n    ChainlitContext,\n    ChainlitContextException,\n    get_context,\n    init_http_context,\n    init_ws_context,\n)\nfrom chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter\nfrom chainlit.session import HTTPSession\n\n\n@pytest.fixture\ndef mock_emitter():\n    return Mock(spec=BaseChainlitEmitter)\n\n\nasync def test_chainlit_context_init_with_websocket(\n    mock_websocket_session, mock_emitter\n):\n    context = ChainlitContext(mock_websocket_session, mock_emitter)\n    assert isinstance(context.emitter, BaseChainlitEmitter)\n    assert context.session == mock_websocket_session\n\n\nasync def test_chainlit_context_init_with_http(mock_http_session):\n    context = ChainlitContext(mock_http_session)\n    assert isinstance(context.emitter, BaseChainlitEmitter)\n    assert context.session == mock_http_session\n\n\nasync def test_init_ws_context(mock_websocket_session):\n    context = init_ws_context(mock_websocket_session)\n    assert isinstance(context, ChainlitContext)\n    assert context.session == mock_websocket_session\n    assert isinstance(context.emitter, ChainlitEmitter)\n\n\nasync def test_init_http_context():\n    context = init_http_context()\n    assert isinstance(context, ChainlitContext)\n    assert isinstance(context.session, HTTPSession)\n    assert isinstance(context.emitter, BaseChainlitEmitter)\n\n\nasync def test_get_context():\n    with pytest.raises(ChainlitContextException):\n        get_context()\n\n    init_http_context()  # Initialize a context\n    context = get_context()\n    assert isinstance(context, ChainlitContext)\n"
  },
  {
    "path": "backend/tests/test_element.py",
    "content": "import uuid\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom chainlit.element import (\n    Audio,\n    CustomElement,\n    Element,\n    ElementDict,\n    File,\n    Image,\n    Pdf,\n    Task,\n    TaskList,\n    TaskStatus,\n    Text,\n    Video,\n)\n\n\n@pytest.mark.asyncio\nclass TestElementBase:\n    \"\"\"Test suite for the base Element class.\"\"\"\n\n    async def test_element_initialization_with_url(self, mock_chainlit_context):\n        \"\"\"Test Element initialization with URL.\"\"\"\n        async with mock_chainlit_context:\n            element = File(name=\"test_file\", url=\"https://example.com/file.pdf\")\n\n            assert element.name == \"test_file\"\n            assert element.url == \"https://example.com/file.pdf\"\n            assert isinstance(element.id, str)\n            uuid.UUID(element.id)  # Verify valid UUID\n            assert element.persisted is False\n            assert element.updatable is False\n\n    async def test_element_initialization_with_content(self, mock_chainlit_context):\n        \"\"\"Test Element initialization with content.\"\"\"\n        async with mock_chainlit_context:\n            content = b\"test content\"\n            element = File(name=\"test_file\", content=content)\n\n            assert element.name == \"test_file\"\n            assert element.content == content\n            assert element.url is None\n            assert element.path is None\n\n    async def test_element_initialization_with_path(self, mock_chainlit_context):\n        \"\"\"Test Element initialization with path.\"\"\"\n        async with mock_chainlit_context:\n            element = File(name=\"test_file\", path=\"/path/to/file.txt\")\n\n            assert element.name == \"test_file\"\n            assert element.path == \"/path/to/file.txt\"\n            assert element.url is None\n            assert element.content is None\n\n    async def test_element_requires_url_path_or_content(self, mock_chainlit_context):\n        \"\"\"Test that Element raises error without url, path, or content.\"\"\"\n        async with mock_chainlit_context:\n            with pytest.raises(ValueError, match=\"Must provide url, path or content\"):\n                File(name=\"test_file\")\n\n    async def test_element_to_dict(self, mock_chainlit_context):\n        \"\"\"Test Element serialization to dictionary.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(\n                name=\"test_file\",\n                url=\"https://example.com/file.pdf\",\n                display=\"inline\",\n            )\n\n            element_dict = element.to_dict()\n\n            assert element_dict[\"name\"] == \"test_file\"\n            assert element_dict[\"url\"] == \"https://example.com/file.pdf\"\n            assert element_dict[\"type\"] == \"file\"\n            assert element_dict[\"id\"] == element.id\n            assert element_dict[\"threadId\"] == ctx.session.thread_id\n            assert element_dict[\"display\"] == \"inline\"\n\n    async def test_element_send(self, mock_chainlit_context):\n        \"\"\"Test Element.send() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(name=\"test_file\", url=\"https://example.com/file.pdf\")\n\n            await element.send(for_id=\"message_123\")\n\n            assert element.for_id == \"message_123\"\n            ctx.emitter.send_element.assert_called_once()\n\n    async def test_element_remove(self, mock_chainlit_context):\n        \"\"\"Test Element.remove() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(name=\"test_file\", url=\"https://example.com/file.pdf\")\n\n            await element.remove()\n\n            ctx.emitter.emit.assert_called_once_with(\n                \"remove_element\", {\"id\": element.id}\n            )\n\n    async def test_element_display_options(self, mock_chainlit_context):\n        \"\"\"Test Element display options.\"\"\"\n        async with mock_chainlit_context:\n            element_inline = File(\n                name=\"test\", url=\"https://example.com/file.pdf\", display=\"inline\"\n            )\n            element_side = File(\n                name=\"test\", url=\"https://example.com/file.pdf\", display=\"side\"\n            )\n            element_page = File(\n                name=\"test\", url=\"https://example.com/file.pdf\", display=\"page\"\n            )\n\n            assert element_inline.display == \"inline\"\n            assert element_side.display == \"side\"\n            assert element_page.display == \"page\"\n\n    async def test_element_from_dict_file(self, mock_chainlit_context):\n        \"\"\"Test Element.from_dict() for File type.\"\"\"\n        async with mock_chainlit_context:\n            element_dict: ElementDict = {\n                \"id\": str(uuid.uuid4()),\n                \"name\": \"test_file\",\n                \"type\": \"file\",\n                \"url\": \"https://example.com/file.pdf\",\n                \"display\": \"inline\",\n            }\n\n            element = Element.from_dict(element_dict)\n\n            assert isinstance(element, File)\n            assert element.name == \"test_file\"\n            assert element.url == \"https://example.com/file.pdf\"\n\n    async def test_element_from_dict_image(self, mock_chainlit_context):\n        \"\"\"Test Element.from_dict() for Image type.\"\"\"\n        async with mock_chainlit_context:\n            element_dict: ElementDict = {\n                \"id\": str(uuid.uuid4()),\n                \"name\": \"test_image\",\n                \"type\": \"image\",\n                \"url\": \"https://example.com/image.png\",\n                \"display\": \"inline\",\n            }\n\n            element = Element.from_dict(element_dict)\n\n            assert isinstance(element, Image)\n            assert element.name == \"test_image\"\n            assert element.type == \"image\"\n\n    async def test_element_infer_type_from_mime(self):\n        \"\"\"Test Element.infer_type_from_mime() method.\"\"\"\n        assert Element.infer_type_from_mime(\"image/png\") == \"image\"\n        assert Element.infer_type_from_mime(\"image/jpeg\") == \"image\"\n        assert Element.infer_type_from_mime(\"application/pdf\") == \"pdf\"\n        assert Element.infer_type_from_mime(\"audio/mp3\") == \"audio\"\n        assert Element.infer_type_from_mime(\"video/mp4\") == \"video\"\n        assert Element.infer_type_from_mime(\"text/plain\") == \"file\"\n        assert Element.infer_type_from_mime(\"application/json\") == \"file\"\n\n\n@pytest.mark.asyncio\nclass TestImageElement:\n    \"\"\"Test suite for Image element.\"\"\"\n\n    async def test_image_initialization(self, mock_chainlit_context):\n        \"\"\"Test Image element initialization.\"\"\"\n        async with mock_chainlit_context:\n            image = Image(\n                name=\"test_image\",\n                url=\"https://example.com/image.png\",\n                size=\"large\",\n            )\n\n            assert image.type == \"image\"\n            assert image.name == \"test_image\"\n            assert image.size == \"large\"\n\n    async def test_image_size_options(self, mock_chainlit_context):\n        \"\"\"Test Image size options.\"\"\"\n        async with mock_chainlit_context:\n            small = Image(name=\"test\", url=\"https://example.com/img.png\", size=\"small\")\n            medium = Image(\n                name=\"test\", url=\"https://example.com/img.png\", size=\"medium\"\n            )\n            large = Image(name=\"test\", url=\"https://example.com/img.png\", size=\"large\")\n\n            assert small.size == \"small\"\n            assert medium.size == \"medium\"\n            assert large.size == \"large\"\n\n\n@pytest.mark.asyncio\nclass TestTextElement:\n    \"\"\"Test suite for Text element.\"\"\"\n\n    async def test_text_initialization(self, mock_chainlit_context):\n        \"\"\"Test Text element initialization.\"\"\"\n        async with mock_chainlit_context:\n            text = Text(name=\"test_text\", content=\"Hello, World!\", language=\"python\")\n\n            assert text.type == \"text\"\n            assert text.name == \"test_text\"\n            assert text.content == \"Hello, World!\"\n            assert text.language == \"python\"\n\n    async def test_text_without_language(self, mock_chainlit_context):\n        \"\"\"Test Text element without language.\"\"\"\n        async with mock_chainlit_context:\n            text = Text(name=\"test_text\", content=\"Plain text\")\n\n            assert text.language is None\n\n\n@pytest.mark.asyncio\nclass TestPdfElement:\n    \"\"\"Test suite for Pdf element.\"\"\"\n\n    async def test_pdf_initialization(self, mock_chainlit_context):\n        \"\"\"Test Pdf element initialization.\"\"\"\n        async with mock_chainlit_context:\n            pdf = Pdf(name=\"test_pdf\", url=\"https://example.com/document.pdf\", page=5)\n\n            assert pdf.type == \"pdf\"\n            assert pdf.name == \"test_pdf\"\n            assert pdf.mime == \"application/pdf\"\n            assert pdf.page == 5\n\n    async def test_pdf_without_page(self, mock_chainlit_context):\n        \"\"\"Test Pdf element without page number.\"\"\"\n        async with mock_chainlit_context:\n            pdf = Pdf(name=\"test_pdf\", url=\"https://example.com/document.pdf\")\n\n            assert pdf.page is None\n\n\n@pytest.mark.asyncio\nclass TestAudioElement:\n    \"\"\"Test suite for Audio element.\"\"\"\n\n    async def test_audio_initialization(self, mock_chainlit_context):\n        \"\"\"Test Audio element initialization.\"\"\"\n        async with mock_chainlit_context:\n            audio = Audio(\n                name=\"test_audio\",\n                url=\"https://example.com/audio.mp3\",\n                auto_play=True,\n            )\n\n            assert audio.type == \"audio\"\n            assert audio.name == \"test_audio\"\n            assert audio.auto_play is True\n\n    async def test_audio_default_auto_play(self, mock_chainlit_context):\n        \"\"\"Test Audio element default auto_play.\"\"\"\n        async with mock_chainlit_context:\n            audio = Audio(name=\"test_audio\", url=\"https://example.com/audio.mp3\")\n\n            assert audio.auto_play is False\n\n\n@pytest.mark.asyncio\nclass TestVideoElement:\n    \"\"\"Test suite for Video element.\"\"\"\n\n    async def test_video_initialization(self, mock_chainlit_context):\n        \"\"\"Test Video element initialization.\"\"\"\n        async with mock_chainlit_context:\n            player_config = {\"youtube\": {\"playerVars\": {\"showinfo\": 1}}}\n            video = Video(\n                name=\"test_video\",\n                url=\"https://example.com/video.mp4\",\n                size=\"large\",\n                player_config=player_config,\n            )\n\n            assert video.type == \"video\"\n            assert video.name == \"test_video\"\n            assert video.size == \"large\"\n            assert video.player_config == player_config\n\n    async def test_video_without_player_config(self, mock_chainlit_context):\n        \"\"\"Test Video element without player config.\"\"\"\n        async with mock_chainlit_context:\n            video = Video(name=\"test_video\", url=\"https://example.com/video.mp4\")\n\n            assert video.player_config is None\n\n\n@pytest.mark.asyncio\nclass TestFileElement:\n    \"\"\"Test suite for File element.\"\"\"\n\n    async def test_file_initialization(self, mock_chainlit_context):\n        \"\"\"Test File element initialization.\"\"\"\n        async with mock_chainlit_context:\n            file = File(name=\"test_file\", url=\"https://example.com/file.txt\")\n\n            assert file.type == \"file\"\n            assert file.name == \"test_file\"\n\n    async def test_file_with_content(self, mock_chainlit_context):\n        \"\"\"Test File element with content.\"\"\"\n        async with mock_chainlit_context:\n            content = b\"File content\"\n            file = File(name=\"test_file\", content=content)\n\n            assert file.content == content\n\n\n@pytest.mark.asyncio\nclass TestTaskListElement:\n    \"\"\"Test suite for TaskList element.\"\"\"\n\n    async def test_tasklist_initialization(self, mock_chainlit_context):\n        \"\"\"Test TaskList element initialization.\"\"\"\n        async with mock_chainlit_context:\n            tasklist = TaskList(name=\"test_tasklist\")\n\n            assert tasklist.type == \"tasklist\"\n            assert tasklist.name == \"test_tasklist\"\n            assert tasklist.tasks == []\n            assert tasklist.status == \"Ready\"\n            assert tasklist.updatable is True\n\n    async def test_tasklist_add_task(self, mock_chainlit_context):\n        \"\"\"Test adding tasks to TaskList.\"\"\"\n        async with mock_chainlit_context:\n            tasklist = TaskList(name=\"test_tasklist\")\n            task1 = Task(title=\"Task 1\", status=TaskStatus.READY)\n            task2 = Task(title=\"Task 2\", status=TaskStatus.RUNNING)\n\n            await tasklist.add_task(task1)\n            await tasklist.add_task(task2)\n\n            assert len(tasklist.tasks) == 2\n            assert tasklist.tasks[0].title == \"Task 1\"\n            assert tasklist.tasks[1].title == \"Task 2\"\n\n    async def test_tasklist_preprocess_content(self, mock_chainlit_context):\n        \"\"\"Test TaskList content preprocessing.\"\"\"\n        async with mock_chainlit_context:\n            tasklist = TaskList(name=\"test_tasklist\", status=\"In Progress\")\n            task = Task(title=\"Test Task\", status=TaskStatus.DONE)\n            await tasklist.add_task(task)\n\n            await tasklist.preprocess_content()\n\n            assert isinstance(tasklist.content, str)\n            assert \"Test Task\" in tasklist.content\n            assert \"done\" in tasklist.content\n            assert \"In Progress\" in tasklist.content\n\n\n@pytest.mark.asyncio\nclass TestTaskClass:\n    \"\"\"Test suite for Task class.\"\"\"\n\n    def test_task_initialization(self):\n        \"\"\"Test Task initialization.\"\"\"\n        task = Task(title=\"Test Task\", status=TaskStatus.READY)\n\n        assert task.title == \"Test Task\"\n        assert task.status == TaskStatus.READY\n        assert task.forId is None\n\n    def test_task_with_for_id(self):\n        \"\"\"Test Task with forId.\"\"\"\n        task = Task(title=\"Test Task\", status=TaskStatus.RUNNING, forId=\"step_123\")\n\n        assert task.forId == \"step_123\"\n\n    def test_task_status_enum(self):\n        \"\"\"Test TaskStatus enum values.\"\"\"\n        assert TaskStatus.READY.value == \"ready\"\n        assert TaskStatus.RUNNING.value == \"running\"\n        assert TaskStatus.FAILED.value == \"failed\"\n        assert TaskStatus.DONE.value == \"done\"\n\n\n@pytest.mark.asyncio\nclass TestCustomElement:\n    \"\"\"Test suite for CustomElement.\"\"\"\n\n    async def test_custom_element_initialization(self, mock_chainlit_context):\n        \"\"\"Test CustomElement initialization.\"\"\"\n        async with mock_chainlit_context:\n            props = {\"key1\": \"value1\", \"key2\": 42}\n            custom = CustomElement(name=\"test_custom\", props=props)\n\n            assert custom.type == \"custom\"\n            assert custom.name == \"test_custom\"\n            assert custom.props == props\n            assert custom.mime == \"application/json\"\n            assert custom.updatable is True\n\n    async def test_custom_element_content_serialization(self, mock_chainlit_context):\n        \"\"\"Test CustomElement content serialization.\"\"\"\n        async with mock_chainlit_context:\n            props = {\"nested\": {\"data\": [1, 2, 3]}}\n            custom = CustomElement(name=\"test_custom\", props=props)\n\n            assert isinstance(custom.content, str)\n            assert \"nested\" in custom.content\n            assert \"data\" in custom.content\n\n    async def test_custom_element_update(self, mock_chainlit_context):\n        \"\"\"Test CustomElement update method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            custom = CustomElement(\n                name=\"test_custom\",\n                props={\"key\": \"value\"},\n                url=\"https://example.com/custom\",\n            )\n            custom.for_id = \"message_123\"\n\n            await custom.update()\n\n            ctx.emitter.send_element.assert_called()\n\n\n@pytest.mark.asyncio\nclass TestElementEdgeCases:\n    \"\"\"Test suite for Element edge cases.\"\"\"\n\n    async def test_element_with_custom_id(self, mock_chainlit_context):\n        \"\"\"Test Element with custom ID.\"\"\"\n        async with mock_chainlit_context:\n            custom_id = str(uuid.uuid4())\n            element = File(\n                id=custom_id, name=\"test_file\", url=\"https://example.com/file.txt\"\n            )\n\n            assert element.id == custom_id\n\n    async def test_element_with_object_key(self, mock_chainlit_context):\n        \"\"\"Test Element with object_key.\"\"\"\n        async with mock_chainlit_context:\n            element = File(\n                name=\"test_file\",\n                url=\"https://example.com/file.txt\",\n                object_key=\"s3://bucket/key\",\n            )\n\n            assert element.object_key == \"s3://bucket/key\"\n\n    async def test_element_with_chainlit_key(self, mock_chainlit_context):\n        \"\"\"Test Element with chainlit_key.\"\"\"\n        async with mock_chainlit_context:\n            element = File(\n                name=\"test_file\",\n                url=\"https://example.com/file.txt\",\n                chainlit_key=\"chainlit_key_123\",\n            )\n\n            assert element.chainlit_key == \"chainlit_key_123\"\n\n    async def test_element_send_without_url_or_key_raises_error(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test that send() raises error without url or chainlit_key.\"\"\"\n        async with mock_chainlit_context as ctx:\n            # Mock persist_file to not set chainlit_key\n            ctx.session.persist_file = AsyncMock(return_value={\"id\": None})\n\n            element = File(name=\"test_file\", content=b\"test content\")\n\n            with pytest.raises(ValueError, match=\"Must provide url or chainlit key\"):\n                await element.send(for_id=\"message_123\", persist=False)\n\n    async def test_element_from_dict_with_missing_fields(self, mock_chainlit_context):\n        \"\"\"Test Element.from_dict() with minimal fields.\"\"\"\n        async with mock_chainlit_context:\n            element_dict: ElementDict = {\n                \"type\": \"file\",\n                \"url\": \"https://example.com/file.txt\",\n            }\n\n            element = Element.from_dict(element_dict)\n\n            assert isinstance(element, File)\n            assert element.name == \"\"\n            assert element.url == \"https://example.com/file.txt\"\n\n    async def test_element_id_uniqueness(self, mock_chainlit_context):\n        \"\"\"Test that each Element gets a unique ID.\"\"\"\n        async with mock_chainlit_context:\n            element1 = File(name=\"file1\", url=\"https://example.com/file1.txt\")\n            element2 = File(name=\"file2\", url=\"https://example.com/file2.txt\")\n            element3 = File(name=\"file3\", url=\"https://example.com/file3.txt\")\n\n            ids = {element1.id, element2.id, element3.id}\n            assert len(ids) == 3  # All unique\n"
  },
  {
    "path": "backend/tests/test_emitter.py",
    "content": "from unittest.mock import MagicMock\n\nimport pytest\n\nfrom chainlit.element import ElementDict\nfrom chainlit.emitter import ChainlitEmitter\nfrom chainlit.step import StepDict\n\n\n@pytest.fixture\ndef emitter(mock_websocket_session):\n    return ChainlitEmitter(mock_websocket_session)\n\n\nasync def test_send_element(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    element_dict: ElementDict = {\n        \"id\": \"test_element\",\n        \"threadId\": None,\n        \"type\": \"text\",\n        \"chainlitKey\": None,\n        \"url\": None,\n        \"objectKey\": None,\n        \"name\": \"Test Element\",\n        \"display\": \"inline\",\n        \"size\": None,\n        \"language\": None,\n        \"page\": None,\n        \"props\": None,\n        \"autoPlay\": None,\n        \"playerConfig\": None,\n        \"forId\": None,\n        \"mime\": None,\n    }\n\n    await emitter.send_element(element_dict)\n\n    mock_websocket_session.emit.assert_called_once_with(\"element\", element_dict)\n\n\nasync def test_send_step(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    step_dict: StepDict = {\n        \"id\": \"test_step\",\n        \"type\": \"user_message\",\n        \"name\": \"Test Step\",\n        \"output\": \"This is a test step\",\n    }\n\n    await emitter.send_step(step_dict)\n\n    mock_websocket_session.emit.assert_called_once_with(\"new_message\", step_dict)\n\n\nasync def test_update_step(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    step_dict: StepDict = {\n        \"id\": \"test_step\",\n        \"type\": \"assistant_message\",\n        \"name\": \"Updated Test Step\",\n        \"output\": \"This is an updated test step\",\n    }\n\n    await emitter.update_step(step_dict)\n\n    mock_websocket_session.emit.assert_called_once_with(\"update_message\", step_dict)\n\n\nasync def test_delete_step(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    step_dict: StepDict = {\n        \"id\": \"test_step\",\n        \"type\": \"system_message\",\n        \"name\": \"Deleted Test Step\",\n        \"output\": \"This step will be deleted\",\n    }\n\n    await emitter.delete_step(step_dict)\n\n    mock_websocket_session.emit.assert_called_once_with(\"delete_message\", step_dict)\n\n\nasync def test_send_timeout(emitter, mock_websocket_session):\n    await emitter.send_timeout(\"ask_timeout\")\n    mock_websocket_session.emit.assert_called_once_with(\"ask_timeout\", {})\n\n\nasync def test_clear(emitter, mock_websocket_session):\n    await emitter.clear(\"clear_ask\")\n    mock_websocket_session.emit.assert_called_once_with(\"clear_ask\", {})\n\n\nasync def test_send_token(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    await emitter.send_token(\"test_id\", \"test_token\", is_sequence=True, is_input=False)\n    mock_websocket_session.emit.assert_called_once_with(\n        \"stream_token\",\n        {\"id\": \"test_id\", \"token\": \"test_token\", \"isSequence\": True, \"isInput\": False},\n    )\n\n\nasync def test_set_chat_settings(emitter, mock_websocket_session):\n    settings = {\"key\": \"value\"}\n    emitter.set_chat_settings(settings)\n    assert emitter.session.chat_settings == settings\n\n\nasync def test_update_token_count(emitter, mock_websocket_session):\n    count = 100\n    await emitter.update_token_count(count)\n    mock_websocket_session.emit.assert_called_once_with(\"token_usage\", count)\n\n\nasync def test_task_start(emitter, mock_websocket_session):\n    await emitter.task_start()\n    mock_websocket_session.emit.assert_called_once_with(\"task_start\", {})\n\n\nasync def test_task_end(emitter, mock_websocket_session):\n    await emitter.task_end()\n    mock_websocket_session.emit.assert_called_once_with(\"task_end\", {})\n\n\nasync def test_stream_start(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    step_dict: StepDict = {\n        \"id\": \"test_stream\",\n        \"type\": \"run\",\n        \"name\": \"Test Stream\",\n        \"output\": \"This is a test stream\",\n    }\n    await emitter.stream_start(step_dict)\n    mock_websocket_session.emit.assert_called_once_with(\"stream_start\", step_dict)\n\n\nasync def test_send_toast(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    message = \"This is a test message\"\n    await emitter.send_toast(message)\n    mock_websocket_session.emit.assert_called_once_with(\n        \"toast\", {\"message\": message, \"type\": \"info\"}\n    )\n\n\nasync def test_send_toast_with_type(\n    emitter: ChainlitEmitter, mock_websocket_session: MagicMock\n) -> None:\n    message = \"This is a test message\"\n    await emitter.send_toast(message, type=\"error\")\n    mock_websocket_session.emit.assert_called_once_with(\n        \"toast\", {\"message\": message, \"type\": \"error\"}\n    )\n\n\nasync def test_send_toast_invalid_type(emitter: ChainlitEmitter) -> None:\n    message = \"This is a test message\"\n    with pytest.raises(ValueError, match=\"Invalid toast type: invalid\"):\n        await emitter.send_toast(message, type=\"invalid\")  # type: ignore[arg-type]\n"
  },
  {
    "path": "backend/tests/test_input_widget.py",
    "content": "import pytest\n\nfrom chainlit.input_widget import (\n    Checkbox,\n    MultiSelect,\n    NumberInput,\n    RadioGroup,\n    Select,\n    Slider,\n    Switch,\n    Tab,\n    Tags,\n    TextInput,\n)\n\n\nclass TestInputWidgetBase:\n    \"\"\"Test suite for base InputWidget validation.\"\"\"\n\n    def test_input_widget_requires_id_and_label(self):\n        \"\"\"Test that InputWidget requires both id and label.\"\"\"\n        with pytest.raises(ValueError, match=\"Must provide key and label\"):\n            Switch(id=\"\", label=\"Test Label\")\n\n        with pytest.raises(ValueError, match=\"Must provide key and label\"):\n            Switch(id=\"test_id\", label=\"\")\n\n\nclass TestSwitchWidget:\n    \"\"\"Test suite for Switch input widget.\"\"\"\n\n    def test_switch_initialization(self):\n        \"\"\"Test Switch widget initialization.\"\"\"\n        switch = Switch(id=\"test_switch\", label=\"Enable Feature\")\n\n        assert switch.id == \"test_switch\"\n        assert switch.label == \"Enable Feature\"\n        assert switch.type == \"switch\"\n        assert switch.initial is False\n        assert switch.disabled is False\n\n    def test_switch_with_initial_value(self):\n        \"\"\"Test Switch widget with initial value.\"\"\"\n        switch = Switch(id=\"test_switch\", label=\"Enable Feature\", initial=True)\n\n        assert switch.initial is True\n\n    def test_switch_with_tooltip_and_description(self):\n        \"\"\"Test Switch widget with tooltip and description.\"\"\"\n        switch = Switch(\n            id=\"test_switch\",\n            label=\"Enable Feature\",\n            tooltip=\"Toggle this feature\",\n            description=\"This enables the advanced feature\",\n        )\n\n        assert switch.tooltip == \"Toggle this feature\"\n        assert switch.description == \"This enables the advanced feature\"\n\n    def test_switch_disabled(self):\n        \"\"\"Test Switch widget in disabled state.\"\"\"\n        switch = Switch(id=\"test_switch\", label=\"Enable Feature\", disabled=True)\n\n        assert switch.disabled is True\n\n    def test_switch_to_dict(self):\n        \"\"\"Test Switch widget serialization.\"\"\"\n        switch = Switch(\n            id=\"test_switch\",\n            label=\"Enable Feature\",\n            initial=True,\n            tooltip=\"Toggle\",\n            description=\"Description\",\n            disabled=False,\n        )\n\n        result = switch.to_dict()\n\n        assert result[\"type\"] == \"switch\"\n        assert result[\"id\"] == \"test_switch\"\n        assert result[\"label\"] == \"Enable Feature\"\n        assert result[\"initial\"] is True\n        assert result[\"tooltip\"] == \"Toggle\"\n        assert result[\"description\"] == \"Description\"\n        assert result[\"disabled\"] is False\n\n\nclass TestSliderWidget:\n    \"\"\"Test suite for Slider input widget.\"\"\"\n\n    def test_slider_initialization(self):\n        \"\"\"Test Slider widget initialization.\"\"\"\n        slider = Slider(id=\"test_slider\", label=\"Temperature\")\n\n        assert slider.id == \"test_slider\"\n        assert slider.label == \"Temperature\"\n        assert slider.type == \"slider\"\n        assert slider.initial == 0\n        assert slider.min == 0\n        assert slider.max == 10\n        assert slider.step == 1\n\n    def test_slider_with_custom_range(self):\n        \"\"\"Test Slider widget with custom range.\"\"\"\n        slider = Slider(\n            id=\"test_slider\",\n            label=\"Temperature\",\n            initial=0.5,\n            min=0.0,\n            max=1.0,\n            step=0.1,\n        )\n\n        assert slider.initial == 0.5\n        assert slider.min == 0.0\n        assert slider.max == 1.0\n        assert slider.step == 0.1\n\n    def test_slider_to_dict(self):\n        \"\"\"Test Slider widget serialization.\"\"\"\n        slider = Slider(\n            id=\"test_slider\",\n            label=\"Temperature\",\n            initial=0.7,\n            min=0.0,\n            max=2.0,\n            step=0.1,\n            tooltip=\"Adjust temperature\",\n        )\n\n        result = slider.to_dict()\n\n        assert result[\"type\"] == \"slider\"\n        assert result[\"id\"] == \"test_slider\"\n        assert result[\"label\"] == \"Temperature\"\n        assert result[\"initial\"] == 0.7\n        assert result[\"min\"] == 0.0\n        assert result[\"max\"] == 2.0\n        assert result[\"step\"] == 0.1\n        assert result[\"tooltip\"] == \"Adjust temperature\"\n\n\nclass TestSelectWidget:\n    \"\"\"Test suite for Select input widget.\"\"\"\n\n    def test_select_with_values(self):\n        \"\"\"Test Select widget with values list.\"\"\"\n        select = Select(\n            id=\"test_select\",\n            label=\"Choose Model\",\n            values=[\"gpt-4\", \"gpt-3.5\", \"claude\"],\n        )\n\n        assert select.id == \"test_select\"\n        assert select.label == \"Choose Model\"\n        assert select.type == \"select\"\n        assert select.items == {\n            \"gpt-4\": \"gpt-4\",\n            \"gpt-3.5\": \"gpt-3.5\",\n            \"claude\": \"claude\",\n        }\n\n    def test_select_with_items(self):\n        \"\"\"Test Select widget with items dict.\"\"\"\n        items = {\"gpt4\": \"GPT-4\", \"gpt35\": \"GPT-3.5\", \"claude\": \"Claude\"}\n        select = Select(id=\"test_select\", label=\"Choose Model\", items=items)\n\n        assert select.items == items\n\n    def test_select_with_initial_index(self):\n        \"\"\"Test Select widget with initial_index.\"\"\"\n        select = Select(\n            id=\"test_select\",\n            label=\"Choose Model\",\n            values=[\"gpt-4\", \"gpt-3.5\", \"claude\"],\n            initial_index=1,\n        )\n\n        assert select.initial == \"gpt-3.5\"\n\n    def test_select_with_initial_value(self):\n        \"\"\"Test Select widget with initial_value.\"\"\"\n        select = Select(\n            id=\"test_select\",\n            label=\"Choose Model\",\n            values=[\"gpt-4\", \"gpt-3.5\", \"claude\"],\n            initial_value=\"claude\",\n        )\n\n        assert select.initial == \"claude\"\n\n    def test_select_requires_values_or_items(self):\n        \"\"\"Test that Select requires either values or items.\"\"\"\n        with pytest.raises(ValueError, match=\"Must provide values or items\"):\n            Select(id=\"test_select\", label=\"Choose Model\")\n\n    def test_select_cannot_have_both_values_and_items(self):\n        \"\"\"Test that Select cannot have both values and items.\"\"\"\n        with pytest.raises(ValueError, match=\"only provide either values or items\"):\n            Select(\n                id=\"test_select\",\n                label=\"Choose Model\",\n                values=[\"a\", \"b\"],\n                items={\"a\": \"A\"},\n            )\n\n    def test_select_initial_index_requires_values(self):\n        \"\"\"Test that initial_index requires values.\"\"\"\n        with pytest.raises(\n            ValueError,\n            match=\"Initial_index can only be used in combination with values\",\n        ):\n            Select(\n                id=\"test_select\",\n                label=\"Choose Model\",\n                items={\"a\": \"A\"},\n                initial_index=0,\n            )\n\n    def test_select_to_dict(self):\n        \"\"\"Test Select widget serialization.\"\"\"\n        select = Select(\n            id=\"test_select\",\n            label=\"Choose Model\",\n            values=[\"gpt-4\", \"gpt-3.5\"],\n            initial_index=0,\n            tooltip=\"Select a model\",\n        )\n\n        result = select.to_dict()\n\n        assert result[\"type\"] == \"select\"\n        assert result[\"id\"] == \"test_select\"\n        assert result[\"label\"] == \"Choose Model\"\n        assert result[\"initial\"] == \"gpt-4\"\n        assert len(result[\"items\"]) == 2\n        assert result[\"items\"][0] == {\"label\": \"gpt-4\", \"value\": \"gpt-4\"}\n        assert result[\"tooltip\"] == \"Select a model\"\n\n\nclass TestTextInputWidget:\n    \"\"\"Test suite for TextInput widget.\"\"\"\n\n    def test_textinput_initialization(self):\n        \"\"\"Test TextInput widget initialization.\"\"\"\n        text_input = TextInput(id=\"test_input\", label=\"Enter Name\")\n\n        assert text_input.id == \"test_input\"\n        assert text_input.label == \"Enter Name\"\n        assert text_input.type == \"textinput\"\n        assert text_input.initial is None\n        assert text_input.placeholder is None\n        assert text_input.multiline is False\n\n    def test_textinput_with_initial_and_placeholder(self):\n        \"\"\"Test TextInput widget with initial value and placeholder.\"\"\"\n        text_input = TextInput(\n            id=\"test_input\",\n            label=\"Enter Name\",\n            initial=\"John Doe\",\n            placeholder=\"Enter your name\",\n        )\n\n        assert text_input.initial == \"John Doe\"\n        assert text_input.placeholder == \"Enter your name\"\n\n    def test_textinput_multiline(self):\n        \"\"\"Test TextInput widget in multiline mode.\"\"\"\n        text_input = TextInput(\n            id=\"test_input\", label=\"Enter Description\", multiline=True\n        )\n\n        assert text_input.multiline is True\n\n    def test_textinput_to_dict(self):\n        \"\"\"Test TextInput widget serialization.\"\"\"\n        text_input = TextInput(\n            id=\"test_input\",\n            label=\"Enter Name\",\n            initial=\"Default\",\n            placeholder=\"Type here\",\n            multiline=True,\n            tooltip=\"Enter your name\",\n        )\n\n        result = text_input.to_dict()\n\n        assert result[\"type\"] == \"textinput\"\n        assert result[\"id\"] == \"test_input\"\n        assert result[\"label\"] == \"Enter Name\"\n        assert result[\"initial\"] == \"Default\"\n        assert result[\"placeholder\"] == \"Type here\"\n        assert result[\"multiline\"] is True\n        assert result[\"tooltip\"] == \"Enter your name\"\n\n\nclass TestNumberInputWidget:\n    \"\"\"Test suite for NumberInput widget.\"\"\"\n\n    def test_numberinput_initialization(self):\n        \"\"\"Test NumberInput widget initialization.\"\"\"\n        number_input = NumberInput(id=\"test_number\", label=\"Enter Age\")\n\n        assert number_input.id == \"test_number\"\n        assert number_input.label == \"Enter Age\"\n        assert number_input.type == \"numberinput\"\n        assert number_input.initial is None\n        assert number_input.placeholder is None\n\n    def test_numberinput_with_initial(self):\n        \"\"\"Test NumberInput widget with initial value.\"\"\"\n        number_input = NumberInput(\n            id=\"test_number\", label=\"Enter Age\", initial=25.5, placeholder=\"Age\"\n        )\n\n        assert number_input.initial == 25.5\n        assert number_input.placeholder == \"Age\"\n\n    def test_numberinput_to_dict(self):\n        \"\"\"Test NumberInput widget serialization.\"\"\"\n        number_input = NumberInput(\n            id=\"test_number\",\n            label=\"Enter Age\",\n            initial=30.0,\n            placeholder=\"Enter a number\",\n            tooltip=\"Your age\",\n        )\n\n        result = number_input.to_dict()\n\n        assert result[\"type\"] == \"numberinput\"\n        assert result[\"id\"] == \"test_number\"\n        assert result[\"label\"] == \"Enter Age\"\n        assert result[\"initial\"] == 30.0\n        assert result[\"placeholder\"] == \"Enter a number\"\n        assert result[\"tooltip\"] == \"Your age\"\n\n\nclass TestTagsWidget:\n    \"\"\"Test suite for Tags widget.\"\"\"\n\n    def test_tags_initialization(self):\n        \"\"\"Test Tags widget initialization.\"\"\"\n        tags = Tags(id=\"test_tags\", label=\"Add Tags\")\n\n        assert tags.id == \"test_tags\"\n        assert tags.label == \"Add Tags\"\n        assert tags.type == \"tags\"\n        assert tags.initial == []\n        assert tags.values == []\n\n    def test_tags_with_initial_values(self):\n        \"\"\"Test Tags widget with initial values.\"\"\"\n        tags = Tags(\n            id=\"test_tags\",\n            label=\"Add Tags\",\n            initial=[\"python\", \"javascript\"],\n            values=[\"python\", \"javascript\", \"go\", \"rust\"],\n        )\n\n        assert tags.initial == [\"python\", \"javascript\"]\n        assert tags.values == [\"python\", \"javascript\", \"go\", \"rust\"]\n\n    def test_tags_to_dict(self):\n        \"\"\"Test Tags widget serialization.\"\"\"\n        tags = Tags(\n            id=\"test_tags\",\n            label=\"Add Tags\",\n            initial=[\"tag1\"],\n            tooltip=\"Add your tags\",\n        )\n\n        result = tags.to_dict()\n\n        assert result[\"type\"] == \"tags\"\n        assert result[\"id\"] == \"test_tags\"\n        assert result[\"label\"] == \"Add Tags\"\n        assert result[\"initial\"] == [\"tag1\"]\n        assert result[\"tooltip\"] == \"Add your tags\"\n\n\nclass TestMultiSelectWidget:\n    \"\"\"Test suite for MultiSelect widget.\"\"\"\n\n    def test_multiselect_with_values(self):\n        \"\"\"Test MultiSelect widget with values list.\"\"\"\n        multi_select = MultiSelect(\n            id=\"test_multiselect\",\n            label=\"Choose Languages\",\n            values=[\"Python\", \"JavaScript\", \"Go\"],\n        )\n\n        assert multi_select.id == \"test_multiselect\"\n        assert multi_select.label == \"Choose Languages\"\n        assert multi_select.type == \"multiselect\"\n        assert multi_select.items == {\n            \"Python\": \"Python\",\n            \"JavaScript\": \"JavaScript\",\n            \"Go\": \"Go\",\n        }\n\n    def test_multiselect_with_items(self):\n        \"\"\"Test MultiSelect widget with items dict.\"\"\"\n        items = {\"py\": \"Python\", \"js\": \"JavaScript\", \"go\": \"Go\"}\n        multi_select = MultiSelect(\n            id=\"test_multiselect\", label=\"Choose Languages\", items=items\n        )\n\n        assert multi_select.items == items\n\n    def test_multiselect_with_initial(self):\n        \"\"\"Test MultiSelect widget with initial selection.\"\"\"\n        multi_select = MultiSelect(\n            id=\"test_multiselect\",\n            label=\"Choose Languages\",\n            values=[\"Python\", \"JavaScript\", \"Go\"],\n            initial=[\"Python\", \"Go\"],\n        )\n\n        assert multi_select.initial == [\"Python\", \"Go\"]\n\n    def test_multiselect_requires_values_or_items(self):\n        \"\"\"Test that MultiSelect requires either values or items.\"\"\"\n        with pytest.raises(ValueError, match=\"Must provide values or items\"):\n            MultiSelect(id=\"test_multiselect\", label=\"Choose Languages\")\n\n    def test_multiselect_cannot_have_both_values_and_items(self):\n        \"\"\"Test that MultiSelect cannot have both values and items.\"\"\"\n        with pytest.raises(ValueError, match=\"only provide either values or items\"):\n            MultiSelect(\n                id=\"test_multiselect\",\n                label=\"Choose Languages\",\n                values=[\"a\", \"b\"],\n                items={\"a\": \"A\"},\n            )\n\n    def test_multiselect_to_dict(self):\n        \"\"\"Test MultiSelect widget serialization.\"\"\"\n        multi_select = MultiSelect(\n            id=\"test_multiselect\",\n            label=\"Choose Languages\",\n            values=[\"Python\", \"JavaScript\"],\n            initial=[\"Python\"],\n            tooltip=\"Select languages\",\n        )\n\n        result = multi_select.to_dict()\n\n        assert result[\"type\"] == \"multiselect\"\n        assert result[\"id\"] == \"test_multiselect\"\n        assert result[\"label\"] == \"Choose Languages\"\n        assert result[\"initial\"] == [\"Python\"]\n        assert len(result[\"items\"]) == 2\n        assert result[\"tooltip\"] == \"Select languages\"\n\n\nclass TestCheckboxWidget:\n    \"\"\"Test suite for Checkbox widget.\"\"\"\n\n    def test_checkbox_initialization(self):\n        \"\"\"Test Checkbox widget initialization.\"\"\"\n        checkbox = Checkbox(id=\"test_checkbox\", label=\"Accept Terms\")\n\n        assert checkbox.id == \"test_checkbox\"\n        assert checkbox.label == \"Accept Terms\"\n        assert checkbox.type == \"checkbox\"\n        assert checkbox.initial is False\n\n    def test_checkbox_with_initial_value(self):\n        \"\"\"Test Checkbox widget with initial value.\"\"\"\n        checkbox = Checkbox(id=\"test_checkbox\", label=\"Accept Terms\", initial=True)\n\n        assert checkbox.initial is True\n\n    def test_checkbox_to_dict(self):\n        \"\"\"Test Checkbox widget serialization.\"\"\"\n        checkbox = Checkbox(\n            id=\"test_checkbox\",\n            label=\"Accept Terms\",\n            initial=True,\n            tooltip=\"Check to accept\",\n            description=\"Terms and conditions\",\n        )\n\n        result = checkbox.to_dict()\n\n        assert result[\"type\"] == \"checkbox\"\n        assert result[\"id\"] == \"test_checkbox\"\n        assert result[\"label\"] == \"Accept Terms\"\n        assert result[\"initial\"] is True\n        assert result[\"tooltip\"] == \"Check to accept\"\n        assert result[\"description\"] == \"Terms and conditions\"\n\n\nclass TestRadioGroupWidget:\n    \"\"\"Test suite for RadioGroup widget.\"\"\"\n\n    def test_radiogroup_with_values(self):\n        \"\"\"Test RadioGroup widget with values list.\"\"\"\n        radio = RadioGroup(\n            id=\"test_radio\", label=\"Choose Size\", values=[\"Small\", \"Medium\", \"Large\"]\n        )\n\n        assert radio.id == \"test_radio\"\n        assert radio.label == \"Choose Size\"\n        assert radio.type == \"radio\"\n        assert radio.items == {\"Small\": \"Small\", \"Medium\": \"Medium\", \"Large\": \"Large\"}\n\n    def test_radiogroup_with_items(self):\n        \"\"\"Test RadioGroup widget with items dict.\"\"\"\n        items = {\"s\": \"Small\", \"m\": \"Medium\", \"l\": \"Large\"}\n        radio = RadioGroup(id=\"test_radio\", label=\"Choose Size\", items=items)\n\n        assert radio.items == items\n\n    def test_radiogroup_with_initial_index(self):\n        \"\"\"Test RadioGroup widget with initial_index.\"\"\"\n        radio = RadioGroup(\n            id=\"test_radio\",\n            label=\"Choose Size\",\n            values=[\"Small\", \"Medium\", \"Large\"],\n            initial_index=1,\n        )\n\n        assert radio.initial == \"Medium\"\n\n    def test_radiogroup_with_initial_value(self):\n        \"\"\"Test RadioGroup widget with initial_value.\"\"\"\n        radio = RadioGroup(\n            id=\"test_radio\",\n            label=\"Choose Size\",\n            values=[\"Small\", \"Medium\", \"Large\"],\n            initial_value=\"Large\",\n        )\n\n        assert radio.initial == \"Large\"\n\n    def test_radiogroup_requires_values_or_items(self):\n        \"\"\"Test that RadioGroup requires either values or items.\"\"\"\n        with pytest.raises(ValueError, match=\"Must provide values or items\"):\n            RadioGroup(id=\"test_radio\", label=\"Choose Size\")\n\n    def test_radiogroup_cannot_have_both_values_and_items(self):\n        \"\"\"Test that RadioGroup cannot have both values and items.\"\"\"\n        with pytest.raises(ValueError, match=\"only provide either values or items\"):\n            RadioGroup(\n                id=\"test_radio\",\n                label=\"Choose Size\",\n                values=[\"a\", \"b\"],\n                items={\"a\": \"A\"},\n            )\n\n    def test_radiogroup_initial_index_requires_values(self):\n        \"\"\"Test that initial_index requires values.\"\"\"\n        with pytest.raises(\n            ValueError,\n            match=\"Initial_index can only be used in combination with values\",\n        ):\n            RadioGroup(\n                id=\"test_radio\", label=\"Choose Size\", items={\"a\": \"A\"}, initial_index=0\n            )\n\n    def test_radiogroup_to_dict(self):\n        \"\"\"Test RadioGroup widget serialization.\"\"\"\n        radio = RadioGroup(\n            id=\"test_radio\",\n            label=\"Choose Size\",\n            values=[\"Small\", \"Medium\"],\n            initial_index=0,\n            tooltip=\"Select size\",\n        )\n\n        result = radio.to_dict()\n\n        assert result[\"type\"] == \"radio\"\n        assert result[\"id\"] == \"test_radio\"\n        assert result[\"label\"] == \"Choose Size\"\n        assert result[\"initial\"] == \"Small\"\n        assert len(result[\"items\"]) == 2\n        assert result[\"items\"][0] == {\"label\": \"Small\", \"value\": \"Small\"}\n        assert result[\"tooltip\"] == \"Select size\"\n\n\nclass TestTabWidget:\n    \"\"\"Test suite for Tab widget.\"\"\"\n\n    def test_tab_initialization(self):\n        \"\"\"Test Tab initialization.\"\"\"\n        tab = Tab(id=\"test_tab\", label=\"Settings\")\n\n        assert tab.id == \"test_tab\"\n        assert tab.label == \"Settings\"\n        assert tab.inputs == []\n\n    def test_tab_with_inputs(self):\n        \"\"\"Test Tab with input widgets.\"\"\"\n        switch = Switch(id=\"switch1\", label=\"Enable\")\n        slider = Slider(id=\"slider1\", label=\"Value\")\n        tab = Tab(id=\"test_tab\", label=\"Settings\", inputs=[switch, slider])\n\n        assert len(tab.inputs) == 2\n        assert tab.inputs[0] == switch\n        assert tab.inputs[1] == slider\n\n    def test_tab_to_dict(self):\n        \"\"\"Test Tab serialization.\"\"\"\n        switch = Switch(id=\"switch1\", label=\"Enable\", initial=True)\n        slider = Slider(id=\"slider1\", label=\"Value\", initial=5)\n        tab = Tab(id=\"test_tab\", label=\"Settings\", inputs=[switch, slider])\n\n        result = tab.to_dict()\n\n        assert result[\"id\"] == \"test_tab\"\n        assert result[\"label\"] == \"Settings\"\n        assert len(result[\"inputs\"]) == 2\n        assert result[\"inputs\"][0][\"type\"] == \"switch\"\n        assert result[\"inputs\"][0][\"id\"] == \"switch1\"\n        assert result[\"inputs\"][1][\"type\"] == \"slider\"\n        assert result[\"inputs\"][1][\"id\"] == \"slider1\"\n\n    def test_tab_to_dict_empty_inputs(self):\n        \"\"\"Test Tab serialization with no inputs.\"\"\"\n        tab = Tab(id=\"test_tab\", label=\"Empty Tab\")\n\n        result = tab.to_dict()\n\n        assert result[\"id\"] == \"test_tab\"\n        assert result[\"label\"] == \"Empty Tab\"\n        assert result[\"inputs\"] == []\n\n\nclass TestInputWidgetEdgeCases:\n    \"\"\"Test suite for InputWidget edge cases.\"\"\"\n\n    def test_all_widgets_have_consistent_common_fields(self):\n        \"\"\"Test that all widgets support common fields.\"\"\"\n        widgets = [\n            Switch(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n            Slider(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n            Checkbox(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n            TextInput(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n            NumberInput(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n            Tags(\n                id=\"test\",\n                label=\"Test\",\n                tooltip=\"Tooltip\",\n                description=\"Description\",\n                disabled=True,\n            ),\n        ]\n\n        for widget in widgets:\n            assert widget.tooltip == \"Tooltip\"\n            assert widget.description == \"Description\"\n            assert widget.disabled is True\n            result = widget.to_dict()\n            assert result[\"tooltip\"] == \"Tooltip\"\n            assert result[\"description\"] == \"Description\"\n            assert result[\"disabled\"] is True\n\n    def test_select_with_complex_items(self):\n        \"\"\"Test Select with complex item labels and values.\"\"\"\n        items = {\n            \"option_1\": \"Option One with Spaces\",\n            \"option_2\": \"Option Two (with parentheses)\",\n            \"option_3\": \"Option Three - with dashes\",\n        }\n        select = Select(id=\"test_select\", label=\"Choose\", items=items)\n\n        result = select.to_dict()\n        assert len(result[\"items\"]) == 3\n        assert {\"label\": \"option_1\", \"value\": \"Option One with Spaces\"} in result[\n            \"items\"\n        ]\n\n    def test_multiselect_initial_with_multiple_values(self):\n        \"\"\"Test MultiSelect with multiple initial values.\"\"\"\n        multi_select = MultiSelect(\n            id=\"test\",\n            label=\"Choose\",\n            values=[\"A\", \"B\", \"C\", \"D\"],\n            initial=[\"A\", \"C\", \"D\"],\n        )\n\n        assert len(multi_select.initial) == 3\n        assert \"A\" in multi_select.initial\n        assert \"C\" in multi_select.initial\n        assert \"D\" in multi_select.initial\n\n    def test_slider_with_negative_range(self):\n        \"\"\"Test Slider with negative range.\"\"\"\n        slider = Slider(id=\"test\", label=\"Test\", min=-10, max=10, initial=-5, step=1)\n\n        assert slider.min == -10\n        assert slider.max == 10\n        assert slider.initial == -5\n\n    def test_textinput_empty_initial_value(self):\n        \"\"\"Test TextInput with empty string as initial value.\"\"\"\n        text_input = TextInput(id=\"test\", label=\"Test\", initial=\"\")\n\n        assert text_input.initial == \"\"\n        result = text_input.to_dict()\n        assert result[\"initial\"] == \"\"\n"
  },
  {
    "path": "backend/tests/test_markdown.py",
    "content": "import os\nimport tempfile\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom chainlit.markdown import DEFAULT_MARKDOWN_STR, get_markdown_str, init_markdown\n\n\nclass TestInitMarkdown:\n    \"\"\"Test suite for init_markdown function.\"\"\"\n\n    def test_init_markdown_creates_file(self):\n        \"\"\"Test that init_markdown creates chainlit.md if it doesn't exist.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            init_markdown(tmpdir)\n\n            chainlit_md_path = os.path.join(tmpdir, \"chainlit.md\")\n            assert os.path.exists(chainlit_md_path)\n\n            # Verify content is the default markdown\n            with open(chainlit_md_path, encoding=\"utf-8\") as f:\n                content = f.read()\n            assert content == DEFAULT_MARKDOWN_STR\n\n    def test_init_markdown_does_not_overwrite_existing(self):\n        \"\"\"Test that init_markdown doesn't overwrite existing chainlit.md.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            chainlit_md_path = os.path.join(tmpdir, \"chainlit.md\")\n            custom_content = \"# My Custom Markdown\"\n\n            # Create existing file\n            with open(chainlit_md_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(custom_content)\n\n            # Call init_markdown\n            init_markdown(tmpdir)\n\n            # Verify content is unchanged\n            with open(chainlit_md_path, encoding=\"utf-8\") as f:\n                content = f.read()\n            assert content == custom_content\n\n    def test_init_markdown_with_nonexistent_directory(self):\n        \"\"\"Test init_markdown with a directory that doesn't exist.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            nonexistent_dir = os.path.join(tmpdir, \"nonexistent\")\n\n            # Should raise an error when trying to create file in nonexistent dir\n            with pytest.raises(FileNotFoundError):\n                init_markdown(nonexistent_dir)\n\n    def test_init_markdown_creates_utf8_file(self):\n        \"\"\"Test that init_markdown creates file with UTF-8 encoding.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            init_markdown(tmpdir)\n\n            chainlit_md_path = os.path.join(tmpdir, \"chainlit.md\")\n\n            # Verify UTF-8 encoding by reading with explicit encoding\n            with open(chainlit_md_path, encoding=\"utf-8\") as f:\n                content = f.read()\n\n            # Should contain emoji characters from DEFAULT_MARKDOWN_STR\n            assert \"🚀\" in content\n            assert \"🤖\" in content\n\n\nclass TestGetMarkdownStr:\n    \"\"\"Test suite for get_markdown_str function.\"\"\"\n\n    def test_get_markdown_str_returns_default(self):\n        \"\"\"Test get_markdown_str returns default chainlit.md content.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            chainlit_md_path = os.path.join(tmpdir, \"chainlit.md\")\n            content = \"# Default Chainlit Markdown\"\n\n            with open(chainlit_md_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(content)\n\n            result = get_markdown_str(tmpdir, \"en\")\n            assert result == content\n\n    def test_get_markdown_str_returns_translated(self):\n        \"\"\"Test get_markdown_str returns translated markdown when available.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create default markdown\n            default_content = \"# Default English\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(default_content)\n\n            # Create translated markdown\n            translated_content = \"# Français\"\n            with open(\n                os.path.join(tmpdir, \"chainlit_fr.md\"), \"w\", encoding=\"utf-8\"\n            ) as f:\n                f.write(translated_content)\n\n            result = get_markdown_str(tmpdir, \"fr\")\n            assert result == translated_content\n\n    def test_get_markdown_str_falls_back_to_default(self):\n        \"\"\"Test get_markdown_str falls back to default when translation missing.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            default_content = \"# Default English\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(default_content)\n\n            # Request non-existent translation\n            with patch(\"chainlit.markdown.logger\") as mock_logger:\n                result = get_markdown_str(tmpdir, \"es\")\n\n                assert result == default_content\n                mock_logger.warning.assert_called_once()\n                assert \"es\" in str(mock_logger.warning.call_args)\n\n    def test_get_markdown_str_returns_none_when_no_file(self):\n        \"\"\"Test get_markdown_str returns None when no markdown file exists.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            result = get_markdown_str(tmpdir, \"en\")\n            assert result is None\n\n    def test_get_markdown_str_with_utf8_content(self):\n        \"\"\"Test get_markdown_str handles UTF-8 content correctly.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            content = \"# Welcome 欢迎 🎉\\n\\nこんにちは\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(content)\n\n            result = get_markdown_str(tmpdir, \"en\")\n            assert result == content\n            assert \"欢迎\" in result\n            assert \"こんにちは\" in result\n\n    def test_get_markdown_str_prevents_path_traversal(self):\n        \"\"\"Test get_markdown_str prevents path traversal attacks.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create default markdown\n            default_content = \"# Default\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(default_content)\n\n            # Try to access file outside root using path traversal\n            # The is_path_inside check should prevent this\n            result = get_markdown_str(tmpdir, \"../../../etc/passwd\")\n\n            # Should fall back to default since traversal is blocked\n            assert result == default_content\n\n    def test_get_markdown_str_with_multiple_languages(self):\n        \"\"\"Test get_markdown_str with multiple language files.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create multiple language files\n            languages = {\n                \"en\": \"# English\",\n                \"fr\": \"# Français\",\n                \"es\": \"# Español\",\n                \"ja\": \"# 日本語\",\n            }\n\n            # Create default\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(languages[\"en\"])\n\n            # Create translations\n            for lang, content in languages.items():\n                if lang != \"en\":\n                    path = os.path.join(tmpdir, f\"chainlit_{lang}.md\")\n                    with open(path, \"w\", encoding=\"utf-8\") as f:\n                        f.write(content)\n\n            # Test each language\n            for lang, expected_content in languages.items():\n                result = get_markdown_str(tmpdir, lang)\n                assert result == expected_content\n\n    def test_get_markdown_str_with_empty_file(self):\n        \"\"\"Test get_markdown_str with empty markdown file.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create empty file\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(\"\")\n\n            result = get_markdown_str(tmpdir, \"en\")\n            assert result == \"\"\n\n    def test_get_markdown_str_with_large_file(self):\n        \"\"\"Test get_markdown_str with large markdown file.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create large content\n            large_content = \"# Header\\n\" + (\"Lorem ipsum dolor sit amet.\\n\" * 1000)\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(large_content)\n\n            result = get_markdown_str(tmpdir, \"en\")\n            assert result == large_content\n            assert len(result) > 10000\n\n\nclass TestDefaultMarkdownStr:\n    \"\"\"Test suite for DEFAULT_MARKDOWN_STR constant.\"\"\"\n\n    def test_default_markdown_str_is_string(self):\n        \"\"\"Test that DEFAULT_MARKDOWN_STR is a string.\"\"\"\n        assert isinstance(DEFAULT_MARKDOWN_STR, str)\n\n    def test_default_markdown_str_not_empty(self):\n        \"\"\"Test that DEFAULT_MARKDOWN_STR is not empty.\"\"\"\n        assert len(DEFAULT_MARKDOWN_STR) > 0\n\n    def test_default_markdown_str_contains_welcome(self):\n        \"\"\"Test that DEFAULT_MARKDOWN_STR contains welcome message.\"\"\"\n        assert \"Welcome to Chainlit\" in DEFAULT_MARKDOWN_STR\n\n    def test_default_markdown_str_contains_links(self):\n        \"\"\"Test that DEFAULT_MARKDOWN_STR contains useful links.\"\"\"\n        assert \"Documentation\" in DEFAULT_MARKDOWN_STR\n        assert \"Discord\" in DEFAULT_MARKDOWN_STR\n        assert \"https://docs.chainlit.io\" in DEFAULT_MARKDOWN_STR\n\n    def test_default_markdown_str_is_valid_markdown(self):\n        \"\"\"Test that DEFAULT_MARKDOWN_STR contains valid markdown syntax.\"\"\"\n        assert \"#\" in DEFAULT_MARKDOWN_STR  # Headers\n        assert \"**\" in DEFAULT_MARKDOWN_STR  # Bold\n        assert \"[\" in DEFAULT_MARKDOWN_STR  # Links\n        assert \"](\" in DEFAULT_MARKDOWN_STR  # Link syntax\n\n\nclass TestMarkdownEdgeCases:\n    \"\"\"Test suite for markdown edge cases.\"\"\"\n\n    def test_init_markdown_with_special_characters_in_path(self):\n        \"\"\"Test init_markdown with special characters in directory path.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            special_dir = os.path.join(tmpdir, \"test dir with spaces\")\n            os.makedirs(special_dir)\n\n            init_markdown(special_dir)\n\n            chainlit_md_path = os.path.join(special_dir, \"chainlit.md\")\n            assert os.path.exists(chainlit_md_path)\n\n    def test_get_markdown_str_with_symlink(self):\n        \"\"\"Test get_markdown_str with symlinked markdown file.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create original file\n            original_dir = os.path.join(tmpdir, \"original\")\n            os.makedirs(original_dir)\n            original_file = os.path.join(original_dir, \"chainlit.md\")\n            content = \"# Original Content\"\n            with open(original_file, \"w\", encoding=\"utf-8\") as f:\n                f.write(content)\n\n            # Create symlink directory\n            link_dir = os.path.join(tmpdir, \"link\")\n            os.makedirs(link_dir)\n            link_file = os.path.join(link_dir, \"chainlit.md\")\n\n            # Create symlink (skip on Windows if no permissions)\n            try:\n                os.symlink(original_file, link_file)\n                result = get_markdown_str(link_dir, \"en\")\n                assert result == content\n            except OSError:\n                pytest.skip(\"Symlink creation not supported\")\n\n    def test_get_markdown_str_with_relative_path(self):\n        \"\"\"Test get_markdown_str with relative path.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            content = \"# Test Content\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(content)\n\n            # Use relative path\n            original_cwd = os.getcwd()\n            try:\n                os.chdir(tmpdir)\n                result = get_markdown_str(\".\", \"en\")\n                assert result == content\n            finally:\n                os.chdir(original_cwd)\n\n    def test_get_markdown_str_language_case_sensitivity(self):\n        \"\"\"Test get_markdown_str language code is used as-is in filename.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            default_content = \"# Default\"\n            with open(os.path.join(tmpdir, \"chainlit.md\"), \"w\", encoding=\"utf-8\") as f:\n                f.write(default_content)\n\n            # Create a language file\n            fr_content = \"# Français\"\n            with open(\n                os.path.join(tmpdir, \"chainlit_fr.md\"), \"w\", encoding=\"utf-8\"\n            ) as f:\n                f.write(fr_content)\n\n            # Test exact match - should get the file\n            result = get_markdown_str(tmpdir, \"fr\")\n            assert result == fr_content\n\n            # Test different case that doesn't exist - should fall back to default\n            # Note: On case-insensitive file systems (Windows), this might still find the file\n            # On case-sensitive file systems (Linux), it will fall back to default\n            with patch(\"chainlit.markdown.logger\"):\n                result_different = get_markdown_str(tmpdir, \"es\")\n                assert result_different == default_content\n\n    def test_init_markdown_concurrent_calls(self):\n        \"\"\"Test init_markdown with concurrent calls (race condition).\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Call init_markdown multiple times\n            init_markdown(tmpdir)\n            init_markdown(tmpdir)\n            init_markdown(tmpdir)\n\n            # Should only have one file with default content\n            chainlit_md_path = os.path.join(tmpdir, \"chainlit.md\")\n            assert os.path.exists(chainlit_md_path)\n\n            with open(chainlit_md_path, encoding=\"utf-8\") as f:\n                content = f.read()\n            assert content == DEFAULT_MARKDOWN_STR\n"
  },
  {
    "path": "backend/tests/test_mcp.py",
    "content": "import sys\nfrom unittest.mock import patch\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom chainlit.mcp import (\n    HttpMcpConnection,\n    SseMcpConnection,\n    StdioMcpConnection,\n    validate_mcp_command,\n)\n\n\nclass TestStdioMcpConnection:\n    \"\"\"Test suite for StdioMcpConnection model.\"\"\"\n\n    def test_stdio_connection_initialization(self):\n        \"\"\"Test StdioMcpConnection initialization.\"\"\"\n        connection = StdioMcpConnection(\n            name=\"test_server\", command=\"python\", args=[\"-m\", \"mcp_server\"]\n        )\n\n        assert connection.name == \"test_server\"\n        assert connection.command == \"python\"\n        assert connection.args == [\"-m\", \"mcp_server\"]\n        assert connection.clientType == \"stdio\"\n\n    def test_stdio_connection_with_empty_args(self):\n        \"\"\"Test StdioMcpConnection with empty args list.\"\"\"\n        connection = StdioMcpConnection(name=\"test_server\", command=\"node\", args=[])\n\n        assert connection.args == []\n        assert connection.clientType == \"stdio\"\n\n    def test_stdio_connection_requires_name(self):\n        \"\"\"Test that StdioMcpConnection requires name.\"\"\"\n        with pytest.raises(ValidationError):\n            StdioMcpConnection(command=\"python\", args=[])\n\n    def test_stdio_connection_requires_command(self):\n        \"\"\"Test that StdioMcpConnection requires command.\"\"\"\n        with pytest.raises(ValidationError):\n            StdioMcpConnection(name=\"test_server\", args=[])\n\n    def test_stdio_connection_requires_args(self):\n        \"\"\"Test that StdioMcpConnection requires args.\"\"\"\n        with pytest.raises(ValidationError):\n            StdioMcpConnection(name=\"test_server\", command=\"python\")\n\n    def test_stdio_connection_client_type_is_literal(self):\n        \"\"\"Test that clientType is always 'stdio'.\"\"\"\n        connection = StdioMcpConnection(name=\"test_server\", command=\"python\", args=[])\n\n        assert connection.clientType == \"stdio\"\n\n    def test_stdio_connection_serialization(self):\n        \"\"\"Test StdioMcpConnection serialization.\"\"\"\n        connection = StdioMcpConnection(\n            name=\"test_server\", command=\"python\", args=[\"-m\", \"server\"]\n        )\n\n        data = connection.model_dump()\n\n        assert data[\"name\"] == \"test_server\"\n        assert data[\"command\"] == \"python\"\n        assert data[\"args\"] == [\"-m\", \"server\"]\n        assert data[\"clientType\"] == \"stdio\"\n\n\nclass TestSseMcpConnection:\n    \"\"\"Test suite for SseMcpConnection model.\"\"\"\n\n    def test_sse_connection_initialization(self):\n        \"\"\"Test SseMcpConnection initialization.\"\"\"\n        connection = SseMcpConnection(name=\"test_server\", url=\"https://example.com/mcp\")\n\n        assert connection.name == \"test_server\"\n        assert connection.url == \"https://example.com/mcp\"\n        assert connection.headers is None\n        assert connection.clientType == \"sse\"\n\n    def test_sse_connection_with_headers(self):\n        \"\"\"Test SseMcpConnection with headers.\"\"\"\n        headers = {\"Authorization\": \"Bearer token123\", \"X-Custom\": \"value\"}\n        connection = SseMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\", headers=headers\n        )\n\n        assert connection.headers == headers\n\n    def test_sse_connection_requires_name(self):\n        \"\"\"Test that SseMcpConnection requires name.\"\"\"\n        with pytest.raises(ValidationError):\n            SseMcpConnection(url=\"https://example.com/mcp\")\n\n    def test_sse_connection_requires_url(self):\n        \"\"\"Test that SseMcpConnection requires url.\"\"\"\n        with pytest.raises(ValidationError):\n            SseMcpConnection(name=\"test_server\")\n\n    def test_sse_connection_client_type_is_literal(self):\n        \"\"\"Test that clientType is always 'sse'.\"\"\"\n        connection = SseMcpConnection(name=\"test_server\", url=\"https://example.com/mcp\")\n\n        assert connection.clientType == \"sse\"\n\n    def test_sse_connection_serialization(self):\n        \"\"\"Test SseMcpConnection serialization.\"\"\"\n        headers = {\"Authorization\": \"Bearer token\"}\n        connection = SseMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\", headers=headers\n        )\n\n        data = connection.model_dump()\n\n        assert data[\"name\"] == \"test_server\"\n        assert data[\"url\"] == \"https://example.com/mcp\"\n        assert data[\"headers\"] == headers\n        assert data[\"clientType\"] == \"sse\"\n\n\nclass TestHttpMcpConnection:\n    \"\"\"Test suite for HttpMcpConnection model.\"\"\"\n\n    def test_http_connection_initialization(self):\n        \"\"\"Test HttpMcpConnection initialization.\"\"\"\n        connection = HttpMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\"\n        )\n\n        assert connection.name == \"test_server\"\n        assert connection.url == \"https://example.com/mcp\"\n        assert connection.headers is None\n        assert connection.clientType == \"streamable-http\"\n\n    def test_http_connection_with_headers(self):\n        \"\"\"Test HttpMcpConnection with headers.\"\"\"\n        headers = {\n            \"Authorization\": \"Bearer token123\",\n            \"Content-Type\": \"application/json\",\n        }\n        connection = HttpMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\", headers=headers\n        )\n\n        assert connection.headers == headers\n\n    def test_http_connection_requires_name(self):\n        \"\"\"Test that HttpMcpConnection requires name.\"\"\"\n        with pytest.raises(ValidationError):\n            HttpMcpConnection(url=\"https://example.com/mcp\")\n\n    def test_http_connection_requires_url(self):\n        \"\"\"Test that HttpMcpConnection requires url.\"\"\"\n        with pytest.raises(ValidationError):\n            HttpMcpConnection(name=\"test_server\")\n\n    def test_http_connection_client_type_is_literal(self):\n        \"\"\"Test that clientType is always 'streamable-http'.\"\"\"\n        connection = HttpMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\"\n        )\n\n        assert connection.clientType == \"streamable-http\"\n\n    def test_http_connection_serialization(self):\n        \"\"\"Test HttpMcpConnection serialization.\"\"\"\n        headers = {\"Authorization\": \"Bearer token\"}\n        connection = HttpMcpConnection(\n            name=\"test_server\", url=\"https://example.com/mcp\", headers=headers\n        )\n\n        data = connection.model_dump()\n\n        assert data[\"name\"] == \"test_server\"\n        assert data[\"url\"] == \"https://example.com/mcp\"\n        assert data[\"headers\"] == headers\n        assert data[\"clientType\"] == \"streamable-http\"\n\n\nclass TestValidateMcpCommand:\n    \"\"\"Test suite for validate_mcp_command function.\"\"\"\n\n    def test_validate_simple_command(self):\n        \"\"\"Test validation of a simple command.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\", \"node\"]\n\n            env, executable, args = validate_mcp_command(\"python -m mcp_server\")\n\n            assert env == {}\n            assert executable == \"python\"\n            assert args == [\"-m\", \"mcp_server\"]\n\n    def test_validate_command_with_path(self):\n        \"\"\"Test validation of command with full path.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            env, executable, args = validate_mcp_command(\n                \"/usr/bin/python -m mcp_server\"\n            )\n\n            assert env == {}\n            assert executable == \"/usr/bin/python\"\n            assert args == [\"-m\", \"mcp_server\"]\n\n    def test_validate_command_with_windows_path(self):\n        \"\"\"Test validation of command with Windows path (using forward slashes or quoted).\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python.exe\"]\n\n            # Use forward slashes which work on Windows and don't get escaped by shlex\n            env, executable, args = validate_mcp_command(\n                \"C:/Python/python.exe -m mcp_server\"\n            )\n\n            assert env == {}\n            assert executable == \"C:/Python/python.exe\"\n            assert args == [\"-m\", \"mcp_server\"]\n\n    def test_validate_command_with_env_vars(self):\n        \"\"\"Test validation of command with environment variables.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"node\"]\n\n            env, executable, args = validate_mcp_command(\n                \"MY_VAR=value NODE_ENV=production node server.js\"\n            )\n\n            assert env == {\"MY_VAR\": \"value\", \"NODE_ENV\": \"production\"}\n            assert executable == \"node\"\n            assert args == [\"server.js\"]\n\n    def test_validate_command_with_env_var_with_spaces(self):\n        \"\"\"Test validation of command with env var containing spaces.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            env, executable, args = validate_mcp_command(\n                'MY_VAR=\"value with spaces\" python script.py'\n            )\n\n            assert env == {\"MY_VAR\": \"value with spaces\"}\n            assert executable == \"python\"\n            assert args == [\"script.py\"]\n\n    def test_validate_command_with_quoted_args(self):\n        \"\"\"Test validation of command with quoted arguments.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            env, executable, args = validate_mcp_command(\n                'python script.py --arg \"value with spaces\"'\n            )\n\n            assert env == {}\n            assert executable == \"python\"\n            assert args == [\"script.py\", \"--arg\", \"value with spaces\"]\n\n    def test_validate_command_with_multiple_args(self):\n        \"\"\"Test validation of command with multiple arguments.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"node\"]\n\n            env, executable, args = validate_mcp_command(\n                \"node server.js --port 3000 --host localhost\"\n            )\n\n            assert env == {}\n            assert executable == \"node\"\n            assert args == [\"server.js\", \"--port\", \"3000\", \"--host\", \"localhost\"]\n\n    def test_validate_command_not_in_allowed_list(self):\n        \"\"\"Test that validation fails for disallowed executable.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\", \"node\"]\n\n            with pytest.raises(ValueError, match=\"Only commands in\"):\n                validate_mcp_command(\"bash script.sh\")\n\n    def test_validate_empty_command(self):\n        \"\"\"Test that validation fails for empty command.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            with pytest.raises(ValueError, match=\"Empty command string\"):\n                validate_mcp_command(\"\")\n\n    def test_validate_command_with_invalid_syntax(self):\n        \"\"\"Test that validation fails for invalid command syntax.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            with pytest.raises(ValueError, match=\"Invalid command string\"):\n                validate_mcp_command('python \"unclosed quote')\n\n    def test_validate_command_with_none_allowed_executables(self):\n        \"\"\"Test validation when allowed_executables is None (all allowed).\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = None\n\n            env, executable, args = validate_mcp_command(\"any_command --arg value\")\n\n            assert env == {}\n            assert executable == \"any_command\"\n            assert args == [\"--arg\", \"value\"]\n\n    def test_validate_command_with_invalid_env_var_format(self):\n        \"\"\"Test that validation fails for invalid env var format.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            with pytest.raises(ValueError, match=\"Invalid environment variable format\"):\n                validate_mcp_command(\"INVALID_ENV python script.py\")\n\n    def test_validate_command_with_complex_env_vars(self):\n        \"\"\"Test validation with complex environment variables.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            env, executable, args = validate_mcp_command(\n                'API_KEY=sk-123456 BASE_URL=\"https://api.example.com\" python app.py'\n            )\n\n            assert env == {\n                \"API_KEY\": \"sk-123456\",\n                \"BASE_URL\": \"https://api.example.com\",\n            }\n            assert executable == \"python\"\n            assert args == [\"app.py\"]\n\n    def test_validate_command_with_equals_in_arg(self):\n        \"\"\"Test validation with equals sign in argument.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            env, executable, args = validate_mcp_command(\n                \"python script.py --config=value\"\n            )\n\n            assert env == {}\n            assert executable == \"python\"\n            assert args == [\"script.py\", \"--config=value\"]\n\n    def test_validate_command_preserves_arg_order(self):\n        \"\"\"Test that argument order is preserved.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"node\"]\n\n            _, _, args = validate_mcp_command(\n                \"node app.js arg1 arg2 arg3 --flag1 --flag2\"\n            )\n\n            assert args == [\"app.js\", \"arg1\", \"arg2\", \"arg3\", \"--flag1\", \"--flag2\"]\n\n\nclass TestMcpConnectionEdgeCases:\n    \"\"\"Test suite for MCP connection edge cases.\"\"\"\n\n    def test_stdio_connection_with_complex_args(self):\n        \"\"\"Test StdioMcpConnection with complex arguments.\"\"\"\n        connection = StdioMcpConnection(\n            name=\"complex_server\",\n            command=\"python\",\n            args=[\n                \"-m\",\n                \"mcp_server\",\n                \"--config\",\n                \"/path/to/config.json\",\n                \"--verbose\",\n            ],\n        )\n\n        assert len(connection.args) == 5\n        assert connection.args[0] == \"-m\"\n        assert connection.args[3] == \"/path/to/config.json\"\n\n    def test_sse_connection_with_multiple_headers(self):\n        \"\"\"Test SseMcpConnection with multiple headers.\"\"\"\n        headers = {\n            \"Authorization\": \"Bearer token\",\n            \"X-API-Key\": \"key123\",\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n        }\n        connection = SseMcpConnection(\n            name=\"multi_header_server\", url=\"https://api.example.com\", headers=headers\n        )\n\n        assert len(connection.headers) == 4\n        assert connection.headers[\"Authorization\"] == \"Bearer token\"\n        assert connection.headers[\"X-API-Key\"] == \"key123\"\n\n    def test_http_connection_with_localhost_url(self):\n        \"\"\"Test HttpMcpConnection with localhost URL.\"\"\"\n        connection = HttpMcpConnection(\n            name=\"local_server\", url=\"http://localhost:8000/mcp\"\n        )\n\n        assert connection.url == \"http://localhost:8000/mcp\"\n\n    def test_connection_names_can_be_descriptive(self):\n        \"\"\"Test that connection names can be descriptive strings.\"\"\"\n        stdio_conn = StdioMcpConnection(\n            name=\"My Custom MCP Server (Python)\", command=\"python\", args=[]\n        )\n        sse_conn = SseMcpConnection(\n            name=\"Production API Server\", url=\"https://api.example.com\"\n        )\n        http_conn = HttpMcpConnection(\n            name=\"Development Server - Local\", url=\"http://localhost:3000\"\n        )\n\n        assert \"Python\" in stdio_conn.name\n        assert \"Production\" in sse_conn.name\n        assert \"Development\" in http_conn.name\n\n    def test_validate_command_with_special_characters_in_path(self):\n        \"\"\"Test validation with special characters in path.\"\"\"\n        mcp_module = sys.modules[\"chainlit.mcp\"]\n        with patch.object(mcp_module, \"config\") as mock_config:\n            mock_config.features.mcp.stdio.allowed_executables = [\"python\"]\n\n            _, executable, args = validate_mcp_command(\n                \"/opt/my-app/bin/python script.py\"\n            )\n\n            assert executable == \"/opt/my-app/bin/python\"\n            assert args == [\"script.py\"]\n"
  },
  {
    "path": "backend/tests/test_message.py",
    "content": "import asyncio\nimport json\nfrom contextlib import contextmanager\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom chainlit.action import Action\nfrom chainlit.context import ChainlitContext, context_var\nfrom chainlit.message import (\n    AskActionMessage,\n    AskElementMessage,\n    AskFileMessage,\n    AskUserMessage,\n    ErrorMessage,\n    Message,\n    MessageBase,\n)\n\n\n@contextmanager\ndef mock_chainlit_context(session=None):\n    \"\"\"Context manager to set up and tear down Chainlit context.\"\"\"\n    mock_loop = Mock(spec=asyncio.AbstractEventLoop)\n    mock_session = session or Mock()\n    mock_session.thread_id = \"thread_123\"\n\n    with patch(\"asyncio.get_running_loop\", return_value=mock_loop):\n        mock_emitter = AsyncMock()\n        mock_context = ChainlitContext(session=mock_session, emitter=mock_emitter)\n        token = context_var.set(mock_context)\n        try:\n            yield mock_context\n        finally:\n            context_var.reset(token)\n\n\nclass TestMessageBase:\n    \"\"\"Test suite for MessageBase class.\"\"\"\n\n    def test_post_init_sets_thread_id(self):\n        \"\"\"Test that __post_init__ sets thread_id from session.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\")\n            assert msg.thread_id == \"thread_123\"\n\n    def test_post_init_generates_id_if_not_provided(self):\n        \"\"\"Test that __post_init__ generates UUID if id not provided.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\")\n            assert msg.id is not None\n            assert len(msg.id) == 36\n\n    def test_post_init_uses_provided_id(self):\n        \"\"\"Test that __post_init__ uses provided id.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\", id=\"custom_id\")\n            assert msg.id == \"custom_id\"\n\n    def test_from_dict_creates_message(self):\n        \"\"\"Test creating message from dictionary.\"\"\"\n        step_dict = {\n            \"id\": \"msg_123\",\n            \"parentId\": \"parent_123\",\n            \"createdAt\": \"2024-01-01T00:00:00Z\",\n            \"output\": \"Hello world\",\n            \"name\": \"Assistant\",\n            \"command\": \"/test\",\n            \"type\": \"user_message\",\n            \"language\": \"python\",\n            \"metadata\": {\"key\": \"value\"},\n        }\n\n        with mock_chainlit_context():\n            msg = MessageBase.from_dict(step_dict)\n\n            assert msg.id == \"msg_123\"\n            assert msg.parent_id == \"parent_123\"\n            assert msg.created_at == \"2024-01-01T00:00:00Z\"\n            assert msg.content == \"Hello world\"\n            assert msg.author == \"Assistant\"\n            assert msg.command == \"/test\"\n            assert msg.type == \"user_message\"\n            assert msg.language == \"python\"\n            assert msg.metadata == {\"key\": \"value\"}\n\n    def test_from_dict_with_minimal_data(self):\n        \"\"\"Test from_dict with minimal required fields.\"\"\"\n        step_dict = {\n            \"id\": \"msg_123\",\n            \"createdAt\": \"2024-01-01T00:00:00Z\",\n            \"output\": \"Hello\",\n        }\n\n        with mock_chainlit_context():\n            with patch(\"chainlit.message.config\") as mock_config:\n                mock_config.ui.name = \"DefaultBot\"\n                msg = MessageBase.from_dict(step_dict)\n\n                assert msg.id == \"msg_123\"\n                assert msg.content == \"Hello\"\n                assert msg.author == \"DefaultBot\"\n                assert msg.type == \"assistant_message\"\n\n    def test_to_dict_returns_step_dict(self):\n        \"\"\"Test converting message to dictionary.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(\n                content=\"Test content\",\n                author=\"TestBot\",\n                language=\"python\",\n                type=\"user_message\",\n                metadata={\"key\": \"value\"},\n                tags=[\"tag1\", \"tag2\"],\n                id=\"msg_123\",\n                parent_id=\"parent_123\",\n                command=\"/test\",\n            )\n            msg.created_at = \"2024-01-01T00:00:00Z\"\n\n            result = msg.to_dict()\n\n            assert result[\"id\"] == \"msg_123\"\n            assert result[\"threadId\"] == \"thread_123\"\n            assert result[\"parentId\"] == \"parent_123\"\n            assert result[\"createdAt\"] == \"2024-01-01T00:00:00Z\"\n            assert result[\"command\"] == \"/test\"\n            assert result[\"output\"] == \"Test content\"\n            assert result[\"name\"] == \"TestBot\"\n            assert result[\"type\"] == \"user_message\"\n            assert result[\"language\"] == \"python\"\n            assert result[\"streaming\"] is False\n            assert result[\"isError\"] is False\n            assert result[\"waitForAnswer\"] is False\n            assert result[\"metadata\"] == {\"key\": \"value\"}\n            assert result[\"tags\"] == [\"tag1\", \"tag2\"]\n\n    @pytest.mark.asyncio\n    async def test_update_stops_streaming(self):\n        \"\"\"Test that update stops streaming.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"test\")\n            msg.streaming = True\n\n            with patch(\"chainlit.message.chat_context\") as mock_chat_ctx:\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    result = await msg.update()\n\n                    assert msg.streaming is False\n                    assert result is True\n                    mock_chat_ctx.add.assert_called_once_with(msg)\n                    ctx.emitter.update_step.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_update_with_data_layer(self):\n        \"\"\"Test update with data layer.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"test\")\n            mock_data_layer = AsyncMock()\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\n                    \"chainlit.message.get_data_layer\", return_value=mock_data_layer\n                ):\n                    with patch(\"asyncio.create_task\") as mock_create_task:\n                        await msg.update()\n\n                        mock_create_task.assert_called_once()\n                        ctx.emitter.update_step.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_remove_from_chat_context(self):\n        \"\"\"Test removing message from chat context.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"test\", id=\"msg_123\")\n\n            with patch(\"chainlit.message.chat_context\") as mock_chat_ctx:\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    result = await msg.remove()\n\n                    assert result is True\n                    mock_chat_ctx.remove.assert_called_once_with(msg)\n                    ctx.emitter.delete_step.assert_called_once()\n\n\nclass TestMessage:\n    \"\"\"Test suite for Message class.\"\"\"\n\n    def test_message_with_string_content(self):\n        \"\"\"Test creating message with string content.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"Hello world\")\n\n            assert msg.content == \"Hello world\"\n            assert msg.language is None\n\n    def test_message_with_dict_content(self):\n        \"\"\"Test creating message with dict content.\"\"\"\n        with mock_chainlit_context():\n            content_dict = {\"key\": \"value\", \"number\": 42}\n            msg = Message(content=content_dict)\n\n            expected = json.dumps(content_dict, indent=4, ensure_ascii=False)\n            assert msg.content == expected\n            assert msg.language == \"json\"\n\n    def test_message_with_non_serializable_dict(self):\n        \"\"\"Test message with non-JSON-serializable dict.\"\"\"\n        with mock_chainlit_context():\n\n            class NonSerializable:\n                pass\n\n            content_dict = {\"obj\": NonSerializable()}\n            msg = Message(content=content_dict)\n\n            assert msg.language == \"text\"\n            assert \"NonSerializable\" in msg.content\n\n    def test_message_with_non_string_content(self):\n        \"\"\"Test message with non-string, non-dict content.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=12345)\n\n            assert msg.content == \"12345\"\n            assert msg.language == \"text\"\n\n    def test_message_with_custom_author(self):\n        \"\"\"Test message with custom author.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\", author=\"CustomBot\")\n\n            assert msg.author == \"CustomBot\"\n\n    def test_message_with_default_author(self):\n        \"\"\"Test message uses default author from config.\"\"\"\n        with mock_chainlit_context():\n            with patch(\"chainlit.message.config\") as mock_config:\n                mock_config.ui.name = \"DefaultBot\"\n                msg = Message(content=\"test\")\n\n                assert msg.author == \"DefaultBot\"\n\n    def test_message_with_actions(self):\n        \"\"\"Test message with actions.\"\"\"\n        with mock_chainlit_context():\n            action1 = Mock(spec=Action)\n            action2 = Mock(spec=Action)\n            msg = Message(content=\"test\", actions=[action1, action2])\n\n            assert len(msg.actions) == 2\n            assert action1 in msg.actions\n            assert action2 in msg.actions\n\n    def test_message_with_elements(self):\n        \"\"\"Test message with elements.\"\"\"\n        with mock_chainlit_context():\n            element1 = Mock()\n            element2 = Mock()\n            msg = Message(content=\"test\", elements=[element1, element2])\n\n            assert len(msg.elements) == 2\n            assert element1 in msg.elements\n            assert element2 in msg.elements\n\n    @pytest.mark.asyncio\n    async def test_message_send(self):\n        \"\"\"Test sending a message.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"test\")\n\n            with patch(\"chainlit.message.chat_context\") as mock_chat_ctx:\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = None\n\n                        result = await msg.send()\n\n                        assert result == msg\n                        assert msg.created_at is not None\n                        assert msg.streaming is False\n                        mock_chat_ctx.add.assert_called_once_with(msg)\n                        ctx.emitter.send_step.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_message_send_with_author_rename(self):\n        \"\"\"Test sending message with author rename.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\", author=\"OldName\")\n\n            async def rename_author(name):\n                return \"NewName\"\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = rename_author\n\n                        await msg.send()\n\n                        assert msg.author == \"NewName\"\n\n    @pytest.mark.asyncio\n    async def test_message_send_with_actions_and_elements(self):\n        \"\"\"Test sending message with actions and elements.\"\"\"\n        with mock_chainlit_context():\n            action = AsyncMock(spec=Action)\n            element = AsyncMock()\n            msg = Message(content=\"test\", actions=[action], elements=[element])\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = None\n\n                        await msg.send()\n\n                        action.send.assert_called_once()\n                        element.send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_message_update_with_actions(self):\n        \"\"\"Test updating message with new actions.\"\"\"\n        with mock_chainlit_context():\n            action1 = AsyncMock(spec=Action)\n            action1.forId = None\n            action2 = AsyncMock(spec=Action)\n            action2.forId = \"existing_id\"\n\n            msg = Message(content=\"test\", actions=[action1, action2])\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    result = await msg.update()\n\n                    assert result is True\n                    action1.send.assert_called_once()\n                    action2.send.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_message_remove_actions(self):\n        \"\"\"Test removing all actions from message.\"\"\"\n        with mock_chainlit_context():\n            action1 = AsyncMock(spec=Action)\n            action2 = AsyncMock(spec=Action)\n            msg = Message(content=\"test\", actions=[action1, action2])\n\n            await msg.remove_actions()\n\n            action1.remove.assert_called_once()\n            action2.remove.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_stream_token_starts_streaming(self):\n        \"\"\"Test that stream_token starts streaming.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"\")\n\n            await msg.stream_token(\"Hello\")\n\n            assert msg.streaming is True\n            assert msg.content == \"Hello\"\n            ctx.emitter.stream_start.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_stream_token_appends_content(self):\n        \"\"\"Test that stream_token appends to content.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"Hello\")\n            msg.streaming = True\n\n            await msg.stream_token(\" world\")\n\n            assert msg.content == \"Hello world\"\n            ctx.emitter.send_token.assert_called_once_with(\n                id=msg.id, token=\" world\", is_sequence=False\n            )\n\n    @pytest.mark.asyncio\n    async def test_stream_token_with_sequence(self):\n        \"\"\"Test stream_token with is_sequence=True.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"Old content\")\n            msg.streaming = True\n\n            await msg.stream_token(\"New content\", is_sequence=True)\n\n            assert msg.content == \"New content\"\n            ctx.emitter.send_token.assert_called_once_with(\n                id=msg.id, token=\"New content\", is_sequence=True\n            )\n\n    @pytest.mark.asyncio\n    async def test_stream_token_ignores_empty_token(self):\n        \"\"\"Test that empty tokens are ignored.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = Message(content=\"test\")\n\n            await msg.stream_token(\"\")\n\n            assert msg.content == \"test\"\n            ctx.emitter.stream_start.assert_not_called()\n\n\nclass TestErrorMessage:\n    \"\"\"Test suite for ErrorMessage class.\"\"\"\n\n    def test_error_message_initialization(self):\n        \"\"\"Test ErrorMessage initialization.\"\"\"\n        with mock_chainlit_context():\n            msg = ErrorMessage(content=\"An error occurred\")\n\n            assert msg.content == \"An error occurred\"\n            assert msg.author is not None\n            assert msg.type == \"assistant_message\"\n            assert msg.is_error is True\n            assert msg.fail_on_persist_error is False\n\n    def test_error_message_with_custom_author(self):\n        \"\"\"Test ErrorMessage with custom author.\"\"\"\n        with mock_chainlit_context():\n            msg = ErrorMessage(content=\"Error\", author=\"ErrorBot\")\n\n            assert msg.author == \"ErrorBot\"\n\n    def test_error_message_with_fail_on_persist(self):\n        \"\"\"Test ErrorMessage with fail_on_persist_error=True.\"\"\"\n        with mock_chainlit_context():\n            msg = ErrorMessage(content=\"Error\", fail_on_persist_error=True)\n\n            assert msg.fail_on_persist_error is True\n\n    @pytest.mark.asyncio\n    async def test_error_message_send(self):\n        \"\"\"Test sending error message.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = ErrorMessage(content=\"Error occurred\")\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = None\n\n                        result = await msg.send()\n\n                        assert result == msg\n                        ctx.emitter.send_step.assert_called_once()\n\n\nclass TestAskUserMessage:\n    \"\"\"Test suite for AskUserMessage class.\"\"\"\n\n    def test_ask_user_message_initialization(self):\n        \"\"\"Test AskUserMessage initialization.\"\"\"\n        with mock_chainlit_context():\n            msg = AskUserMessage(content=\"What is your name?\")\n\n            assert msg.content == \"What is your name?\"\n            assert msg.author is not None\n            assert msg.timeout == 60\n            assert msg.raise_on_timeout is False\n\n    def test_ask_user_message_with_custom_timeout(self):\n        \"\"\"Test AskUserMessage with custom timeout.\"\"\"\n        with mock_chainlit_context():\n            msg = AskUserMessage(content=\"Question?\", timeout=120)\n\n            assert msg.timeout == 120\n\n    @pytest.mark.asyncio\n    async def test_ask_user_message_send(self):\n        \"\"\"Test sending AskUserMessage.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = AskUserMessage(content=\"Question?\")\n            ctx.emitter.send_ask_user = AsyncMock(return_value={\"output\": \"Answer\"})\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    result = await msg.send()\n\n                    assert result == {\"output\": \"Answer\"}\n                    assert msg.wait_for_answer is False\n                    ctx.emitter.send_ask_user.assert_called_once()\n\n\nclass TestAskFileMessage:\n    \"\"\"Test suite for AskFileMessage class.\"\"\"\n\n    def test_ask_file_message_initialization(self):\n        \"\"\"Test AskFileMessage initialization.\"\"\"\n        with mock_chainlit_context():\n            with patch(\"chainlit.message.config\") as mock_config:\n                mock_config.ui.name = \"Bot\"\n                msg = AskFileMessage(\n                    content=\"Upload a file\", accept=[\"text/plain\", \"application/pdf\"]\n                )\n\n                assert msg.content == \"Upload a file\"\n                assert msg.accept == [\"text/plain\", \"application/pdf\"]\n                assert msg.max_size_mb == 2\n                assert msg.max_files == 1\n\n    def test_ask_file_message_with_custom_limits(self):\n        \"\"\"Test AskFileMessage with custom limits.\"\"\"\n        with mock_chainlit_context():\n            msg = AskFileMessage(\n                content=\"Upload\", accept=[\"image/*\"], max_size_mb=10, max_files=5\n            )\n\n            assert msg.max_size_mb == 10\n            assert msg.max_files == 5\n\n    @pytest.mark.asyncio\n    async def test_ask_file_message_send_with_response(self):\n        \"\"\"Test AskFileMessage send with file response.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = AskFileMessage(content=\"Upload\", accept=[\"text/plain\"])\n            file_response = [\n                {\n                    \"id\": \"file_123\",\n                    \"name\": \"test.txt\",\n                    \"path\": \"/path/to/test.txt\",\n                    \"size\": 1024,\n                    \"type\": \"text/plain\",\n                }\n            ]\n            ctx.emitter.send_ask_user = AsyncMock(return_value=file_response)\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    result = await msg.send()\n\n                    assert result is not None\n                    assert len(result) == 1\n                    assert result[0].id == \"file_123\"\n                    assert result[0].name == \"test.txt\"\n                    assert result[0].path == \"/path/to/test.txt\"\n\n    @pytest.mark.asyncio\n    async def test_ask_file_message_send_with_no_response(self):\n        \"\"\"Test AskFileMessage send with no response.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = AskFileMessage(content=\"Upload\", accept=[\"text/plain\"])\n            ctx.emitter.send_ask_user = AsyncMock(return_value=None)\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    result = await msg.send()\n\n                    assert result is None\n\n\nclass TestAskActionMessage:\n    \"\"\"Test suite for AskActionMessage class.\"\"\"\n\n    def test_ask_action_message_initialization(self):\n        \"\"\"Test AskActionMessage initialization.\"\"\"\n        with mock_chainlit_context():\n            with patch(\"chainlit.message.config\") as mock_config:\n                mock_config.ui.name = \"Bot\"\n                action1 = Mock(spec=Action)\n                action2 = Mock(spec=Action)\n                msg = AskActionMessage(\n                    content=\"Choose an action\", actions=[action1, action2]\n                )\n\n                assert msg.content == \"Choose an action\"\n                assert len(msg.actions) == 2\n\n    @pytest.mark.asyncio\n    async def test_ask_action_message_send_with_response(self):\n        \"\"\"Test AskActionMessage send with action response.\"\"\"\n        with mock_chainlit_context() as ctx:\n            action = AsyncMock(spec=Action)\n            action.id = \"action_123\"\n            msg = AskActionMessage(content=\"Choose\", actions=[action])\n            ctx.emitter.send_ask_user = AsyncMock(\n                return_value={\"id\": \"action_123\", \"label\": \"Confirm\"}\n            )\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    with patch(\"chainlit.message.chat_context\"):\n                        result = await msg.send()\n\n                        assert result == {\"id\": \"action_123\", \"label\": \"Confirm\"}\n                        assert msg.content == \"**Selected:** Confirm\"\n                        action.send.assert_called_once()\n                        action.remove.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_ask_action_message_send_timeout(self):\n        \"\"\"Test AskActionMessage send with timeout.\"\"\"\n        with mock_chainlit_context() as ctx:\n            action = AsyncMock(spec=Action)\n            action.id = \"action_123\"\n            msg = AskActionMessage(content=\"Choose\", actions=[action])\n            ctx.emitter.send_ask_user = AsyncMock(return_value=None)\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    with patch(\"chainlit.message.chat_context\"):\n                        result = await msg.send()\n\n                        assert result is None\n                        assert msg.content == \"Timed out: no action was taken\"\n\n\nclass TestAskElementMessage:\n    \"\"\"Test suite for AskElementMessage class.\"\"\"\n\n    def test_ask_element_message_initialization(self):\n        \"\"\"Test AskElementMessage initialization.\"\"\"\n        with mock_chainlit_context():\n            with patch(\"chainlit.message.config\") as mock_config:\n                mock_config.ui.name = \"Bot\"\n                element = Mock()\n                msg = AskElementMessage(content=\"Submit form\", element=element)\n\n                assert msg.content == \"Submit form\"\n                assert msg.element == element\n\n    @pytest.mark.asyncio\n    async def test_ask_element_message_send_submitted(self):\n        \"\"\"Test AskElementMessage send with submitted response.\"\"\"\n        with mock_chainlit_context() as ctx:\n            element = AsyncMock()\n            element.id = \"element_123\"\n            msg = AskElementMessage(content=\"Submit\", element=element)\n            ctx.emitter.send_ask_user = AsyncMock(\n                return_value={\"submitted\": True, \"data\": {\"field\": \"value\"}}\n            )\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    with patch(\"chainlit.message.chat_context\"):\n                        result = await msg.send()\n\n                        assert result == {\"submitted\": True, \"data\": {\"field\": \"value\"}}\n                        assert msg.content == \"Thanks for submitting\"\n                        element.send.assert_called_once()\n                        element.remove.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_ask_element_message_send_cancelled(self):\n        \"\"\"Test AskElementMessage send with cancelled response.\"\"\"\n        with mock_chainlit_context() as ctx:\n            element = AsyncMock()\n            element.id = \"element_123\"\n            msg = AskElementMessage(content=\"Submit\", element=element)\n            ctx.emitter.send_ask_user = AsyncMock(return_value={\"submitted\": False})\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    with patch(\"chainlit.message.chat_context\"):\n                        result = await msg.send()\n\n                        assert result == {\"submitted\": False}\n                        assert msg.content == \"Cancelled\"\n\n    @pytest.mark.asyncio\n    async def test_ask_element_message_send_timeout(self):\n        \"\"\"Test AskElementMessage send with timeout.\"\"\"\n        with mock_chainlit_context() as ctx:\n            element = AsyncMock()\n            element.id = \"element_123\"\n            msg = AskElementMessage(content=\"Submit\", element=element)\n            ctx.emitter.send_ask_user = AsyncMock(return_value=None)\n\n            with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                with patch(\"chainlit.message.config\") as mock_config:\n                    mock_config.code.author_rename = None\n\n                    with patch(\"chainlit.message.chat_context\"):\n                        result = await msg.send()\n\n                        assert result is None\n                        assert msg.content == \"Timed out\"\n\n\nclass TestMessageEdgeCases:\n    \"\"\"Test suite for message edge cases.\"\"\"\n\n    def test_message_with_none_content(self):\n        \"\"\"Test message handles None content.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=None)\n            assert msg.content == \"None\"\n\n    def test_message_language_override(self):\n        \"\"\"Test that dict content sets language to json.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content={\"key\": \"value\"}, language=\"python\")\n            # Dict content always sets language to json, overriding the parameter\n            assert msg.language == \"json\"\n\n    @pytest.mark.asyncio\n    async def test_message_send_with_none_content(self):\n        \"\"\"Test sending message with None content.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\")\n            msg.content = None\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = None\n\n                        await msg.send()\n\n                        assert msg.content == \"\"\n\n    @pytest.mark.asyncio\n    async def test_ask_message_remove_clears_ask(self):\n        \"\"\"Test that AskMessage remove clears ask state.\"\"\"\n        with mock_chainlit_context() as ctx:\n            msg = AskUserMessage(content=\"Question?\")\n\n            with patch(\"chainlit.message.chat_context\"):\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    await msg.remove()\n\n                    ctx.emitter.clear.assert_called_once_with(\"clear_ask\")\n\n    def test_message_metadata_and_tags(self):\n        \"\"\"Test message with metadata and tags.\"\"\"\n        with mock_chainlit_context():\n            metadata = {\"key1\": \"value1\", \"key2\": 123}\n            tags = [\"important\", \"user-query\"]\n            msg = Message(content=\"test\", metadata=metadata, tags=tags)\n\n            assert msg.metadata == metadata\n            assert msg.tags == tags\n\n    def test_message_to_dict_with_none_metadata(self):\n        \"\"\"Test to_dict with None metadata.\"\"\"\n        with mock_chainlit_context():\n            msg = Message(content=\"test\")\n            msg.metadata = None\n\n            result = msg.to_dict()\n\n            assert result[\"metadata\"] == {}\n"
  },
  {
    "path": "backend/tests/test_modes.py",
    "content": "\"\"\"Tests for Modes system functionality.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nimport chainlit as cl\nfrom chainlit.emitter import ChainlitEmitter\nfrom chainlit.mode import Mode, ModeOption\n\n\n@pytest.fixture\ndef mock_modes():\n    \"\"\"Fixture providing sample modes for testing.\"\"\"\n    return [\n        Mode(\n            id=\"model\",\n            name=\"Model\",\n            options=[\n                ModeOption(\n                    id=\"gpt-4\",\n                    name=\"GPT-4\",\n                    description=\"Most capable model\",\n                    icon=\"sparkles\",\n                    default=True,\n                ),\n                ModeOption(\n                    id=\"gpt-3.5-turbo\",\n                    name=\"GPT-3.5 Turbo\",\n                    description=\"Fast and efficient\",\n                    icon=\"bolt\",\n                    default=False,\n                ),\n            ],\n        ),\n        Mode(\n            id=\"reasoning\",\n            name=\"Reasoning\",\n            options=[\n                ModeOption(id=\"high\", name=\"High\", description=\"Maximum depth\"),\n                ModeOption(\n                    id=\"medium\", name=\"Medium\", description=\"Balanced\", default=True\n                ),\n                ModeOption(id=\"low\", name=\"Low\", description=\"Quick responses\"),\n            ],\n        ),\n    ]\n\n\n@pytest.mark.asyncio\nclass TestModeOption:\n    \"\"\"Test suite for ModeOption dataclass.\"\"\"\n\n    def test_mode_option_required_fields(self):\n        \"\"\"Test ModeOption with required fields only.\"\"\"\n        option = ModeOption(id=\"test\", name=\"Test Option\")\n\n        assert option.id == \"test\"\n        assert option.name == \"Test Option\"\n        assert option.description is None\n        assert option.icon is None\n        assert option.default is False\n\n    def test_mode_option_all_fields(self):\n        \"\"\"Test ModeOption with all fields.\"\"\"\n        option = ModeOption(\n            id=\"gpt-4\",\n            name=\"GPT-4\",\n            description=\"Most capable model\",\n            icon=\"sparkles\",\n            default=True,\n        )\n\n        assert option.id == \"gpt-4\"\n        assert option.name == \"GPT-4\"\n        assert option.description == \"Most capable model\"\n        assert option.icon == \"sparkles\"\n        assert option.default is True\n\n    def test_mode_option_to_dict(self):\n        \"\"\"Test ModeOption serialization.\"\"\"\n        option = ModeOption(\n            id=\"test\",\n            name=\"Test\",\n            description=\"Test desc\",\n            icon=\"star\",\n            default=True,\n        )\n\n        option_dict = option.to_dict()\n\n        assert option_dict[\"id\"] == \"test\"\n        assert option_dict[\"name\"] == \"Test\"\n        assert option_dict[\"description\"] == \"Test desc\"\n        assert option_dict[\"icon\"] == \"star\"\n        assert option_dict[\"default\"] is True\n\n\n@pytest.mark.asyncio\nclass TestMode:\n    \"\"\"Test suite for Mode dataclass.\"\"\"\n\n    def test_mode_creation(self, mock_modes):\n        \"\"\"Test Mode creation with options.\"\"\"\n        mode = mock_modes[0]\n\n        assert mode.id == \"model\"\n        assert mode.name == \"Model\"\n        assert len(mode.options) == 2\n\n    def test_mode_to_dict(self, mock_modes):\n        \"\"\"Test Mode serialization.\"\"\"\n        mode = mock_modes[0]\n        mode_dict = mode.to_dict()\n\n        assert mode_dict[\"id\"] == \"model\"\n        assert mode_dict[\"name\"] == \"Model\"\n        assert len(mode_dict[\"options\"]) == 2\n        assert mode_dict[\"options\"][0][\"id\"] == \"gpt-4\"\n\n    def test_mode_default_option(self, mock_modes):\n        \"\"\"Test finding default option in mode.\"\"\"\n        mode = mock_modes[0]\n\n        default_option = next(\n            (opt for opt in mode.options if opt.default), mode.options[0]\n        )\n\n        assert default_option.id == \"gpt-4\"\n\n    def test_mode_fallback_to_first(self, mock_modes):\n        \"\"\"Test fallback to first option when no default set.\"\"\"\n        mode = Mode(\n            id=\"test\",\n            name=\"Test\",\n            options=[\n                ModeOption(id=\"opt1\", name=\"Option 1\"),\n                ModeOption(id=\"opt2\", name=\"Option 2\"),\n            ],\n        )\n\n        default_option = next(\n            (opt for opt in mode.options if opt.default),\n            mode.options[0] if mode.options else None,\n        )\n\n        assert default_option is not None\n        assert default_option.id == \"opt1\"\n\n\n@pytest.mark.asyncio\nclass TestMessageWithModes:\n    \"\"\"Test suite for Message with modes field.\"\"\"\n\n    async def test_message_with_modes(self, mock_chainlit_context):\n        \"\"\"Test that Message can be created with modes field.\"\"\"\n        async with mock_chainlit_context:\n            modes = {\"model\": \"gpt-4\", \"reasoning\": \"high\"}\n            message = cl.Message(content=\"Test message\", modes=modes)\n\n            assert message.modes == modes\n            assert message.content == \"Test message\"\n\n    async def test_message_to_dict_includes_modes(self, mock_chainlit_context):\n        \"\"\"Test that Message.to_dict() includes the modes field.\"\"\"\n        async with mock_chainlit_context:\n            modes = {\"model\": \"gpt-4\", \"reasoning\": \"medium\"}\n            message = cl.Message(content=\"Test\", modes=modes)\n            message_dict = message.to_dict()\n\n            assert \"modes\" in message_dict\n            assert message_dict[\"modes\"] == modes\n\n    async def test_message_from_dict_with_modes(self, mock_chainlit_context):\n        \"\"\"Test that Message.from_dict() correctly handles modes field.\"\"\"\n        async with mock_chainlit_context:\n            message_dict = {\n                \"id\": \"test-id\",\n                \"content\": \"Test message\",\n                \"modes\": {\"model\": \"gpt-3.5-turbo\", \"reasoning\": \"low\"},\n                \"type\": \"user_message\",\n                \"createdAt\": \"2024-01-01T00:00:00\",\n                \"output\": \"Test message\",\n            }\n            message = cl.Message.from_dict(message_dict)\n\n            assert message.modes == {\"model\": \"gpt-3.5-turbo\", \"reasoning\": \"low\"}\n            assert message.content == \"Test message\"\n\n    async def test_message_without_modes(self, mock_chainlit_context):\n        \"\"\"Test that Message works without modes field (backward compatibility).\"\"\"\n        async with mock_chainlit_context:\n            message = cl.Message(content=\"Test message\")\n\n            assert message.modes is None\n            message_dict = message.to_dict()\n            assert message_dict.get(\"modes\") is None\n\n    async def test_message_send_with_modes(self, mock_chainlit_context):\n        \"\"\"Test that sending a message with modes works.\"\"\"\n        async with mock_chainlit_context as ctx:\n            modes = {\"model\": \"gpt-4\", \"reasoning\": \"high\"}\n            message = cl.Message(content=\"Test\", modes=modes)\n\n            with patch(\"chainlit.message.chat_context\") as mock_chat_ctx:\n                with patch(\"chainlit.message.get_data_layer\", return_value=None):\n                    with patch(\"chainlit.message.config\") as mock_config:\n                        mock_config.code.author_rename = None\n\n                        result = await message.send()\n\n                        assert result == message\n                        assert message.modes == modes\n                        mock_chat_ctx.add.assert_called_once_with(message)\n                        ctx.emitter.send_step.assert_called_once()\n\n                        # Verify the dict sent to emitter includes modes\n                        call_args = ctx.emitter.send_step.call_args[0][0]\n                        assert call_args[\"modes\"] == modes\n\n\n@pytest.mark.asyncio\nclass TestEmitterSetModes:\n    \"\"\"Test suite for emitter set_modes functionality.\"\"\"\n\n    async def test_set_modes(\n        self, mock_modes, mock_websocket_session: MagicMock\n    ) -> None:\n        \"\"\"Test set_modes emits correct event.\"\"\"\n        emitter = ChainlitEmitter(mock_websocket_session)\n        modes_dicts = [mode.to_dict() for mode in mock_modes]\n\n        await emitter.set_modes(mock_modes)\n\n        mock_websocket_session.emit.assert_called_once_with(\"set_modes\", modes_dicts)\n\n    async def test_set_modes_empty_list(\n        self, mock_websocket_session: MagicMock\n    ) -> None:\n        \"\"\"Test set_modes with empty list.\"\"\"\n        emitter = ChainlitEmitter(mock_websocket_session)\n\n        await emitter.set_modes([])\n\n        mock_websocket_session.emit.assert_called_once_with(\"set_modes\", [])\n\n    async def test_set_modes_single_mode(\n        self, mock_websocket_session: MagicMock\n    ) -> None:\n        \"\"\"Test set_modes with single mode.\"\"\"\n        emitter = ChainlitEmitter(mock_websocket_session)\n        mode = Mode(\n            id=\"model\",\n            name=\"Model\",\n            options=[ModeOption(id=\"gpt-4\", name=\"GPT-4\", default=True)],\n        )\n\n        await emitter.set_modes([mode])\n\n        mock_websocket_session.emit.assert_called_once()\n        call_args = mock_websocket_session.emit.call_args\n        assert call_args[0][0] == \"set_modes\"\n        assert len(call_args[0][1]) == 1\n        assert call_args[0][1][0][\"id\"] == \"model\"\n\n\n@pytest.mark.asyncio\nclass TestModeExports:\n    \"\"\"Test that Mode and ModeOption are properly exported.\"\"\"\n\n    def test_mode_exported_from_chainlit(self):\n        \"\"\"Test Mode is exported from chainlit package.\"\"\"\n        assert hasattr(cl, \"Mode\")\n        assert cl.Mode is Mode\n\n    def test_mode_option_exported_from_chainlit(self):\n        \"\"\"Test ModeOption is exported from chainlit package.\"\"\"\n        assert hasattr(cl, \"ModeOption\")\n        assert cl.ModeOption is ModeOption\n"
  },
  {
    "path": "backend/tests/test_oauth_providers.py",
    "content": "import os\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport httpx\nimport pytest\nfrom fastapi import HTTPException\n\nfrom chainlit.oauth_providers import (\n    ACCESS_TOKEN_MISSING,\n    Auth0OAuthProvider,\n    AWSCognitoOAuthProvider,\n    AzureADHybridOAuthProvider,\n    AzureADOAuthProvider,\n    DescopeOAuthProvider,\n    GenericOAuthProvider,\n    GithubOAuthProvider,\n    GitlabOAuthProvider,\n    GoogleOAuthProvider,\n    KeycloakOAuthProvider,\n    OAuthProvider,\n    OktaOAuthProvider,\n    get_configured_oauth_providers,\n    get_oauth_provider,\n)\nfrom chainlit.user import User\n\n\nclass TestOAuthProviderBase:\n    \"\"\"Test suite for OAuthProvider base class.\"\"\"\n\n    def test_oauth_provider_has_required_methods(self):\n        \"\"\"Test that OAuthProvider defines required methods.\"\"\"\n        provider = OAuthProvider()\n\n        # These should be methods\n        assert hasattr(provider, \"is_configured\")\n        assert hasattr(provider, \"get_raw_token_response\")\n        assert hasattr(provider, \"get_token\")\n        assert hasattr(provider, \"get_user_info\")\n        assert hasattr(provider, \"get_env_prefix\")\n        assert hasattr(provider, \"get_prompt\")\n\n    def test_oauth_provider_is_configured_returns_false_when_env_missing(self):\n        \"\"\"Test is_configured returns False when environment variables are missing.\"\"\"\n        provider = OAuthProvider()\n        provider.env = [\"MISSING_VAR_1\", \"MISSING_VAR_2\"]\n\n        assert provider.is_configured() is False\n\n    def test_oauth_provider_is_configured_returns_true_when_env_present(self):\n        \"\"\"Test is_configured returns True when all environment variables are present.\"\"\"\n        provider = OAuthProvider()\n        provider.env = [\"TEST_VAR_1\", \"TEST_VAR_2\"]\n\n        with patch.dict(os.environ, {\"TEST_VAR_1\": \"value1\", \"TEST_VAR_2\": \"value2\"}):\n            assert provider.is_configured() is True\n\n    def test_oauth_provider_get_env_prefix(self):\n        \"\"\"Test get_env_prefix converts id to uppercase with underscores.\"\"\"\n        provider = OAuthProvider()\n        provider.id = \"azure-ad\"\n\n        assert provider.get_env_prefix() == \"AZURE_AD\"\n\n    def test_oauth_provider_get_prompt_returns_provider_specific(self):\n        \"\"\"Test get_prompt returns provider-specific prompt.\"\"\"\n        provider = OAuthProvider()\n        provider.id = \"github\"\n        provider.default_prompt = None\n\n        with patch.dict(os.environ, {\"OAUTH_GITHUB_PROMPT\": \"consent\"}):\n            assert provider.get_prompt() == \"consent\"\n\n    def test_oauth_provider_get_prompt_returns_global(self):\n        \"\"\"Test get_prompt returns global prompt when provider-specific not set.\"\"\"\n        provider = OAuthProvider()\n        provider.id = \"github\"\n        provider.default_prompt = None\n\n        with patch.dict(os.environ, {\"OAUTH_PROMPT\": \"select_account\"}):\n            assert provider.get_prompt() == \"select_account\"\n\n    def test_oauth_provider_get_prompt_returns_default(self):\n        \"\"\"Test get_prompt returns default when no env vars set.\"\"\"\n        provider = OAuthProvider()\n        provider.id = \"github\"\n        provider.default_prompt = \"login\"\n\n        assert provider.get_prompt() == \"login\"\n\n    @pytest.mark.asyncio\n    async def test_oauth_provider_abstract_methods_raise_not_implemented(self):\n        \"\"\"Test that abstract methods raise NotImplementedError.\"\"\"\n        provider = OAuthProvider()\n\n        with pytest.raises(NotImplementedError):\n            await provider.get_raw_token_response(\"code\", \"url\")\n\n        with pytest.raises(NotImplementedError):\n            await provider.get_token(\"code\", \"url\")\n\n        with pytest.raises(NotImplementedError):\n            await provider.get_user_info(\"token\")\n\n\nclass TestGithubOAuthProvider:\n    \"\"\"Test suite for GithubOAuthProvider.\"\"\"\n\n    def test_github_provider_initialization(self):\n        \"\"\"Test GithubOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_client_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            assert provider.id == \"github\"\n            assert provider.client_id == \"test_client_id\"\n            assert provider.client_secret == \"test_secret\"\n            assert \"scope\" in provider.authorize_params\n            assert provider.authorize_params[\"scope\"] == \"user:email\"\n\n    def test_github_provider_with_custom_urls(self):\n        \"\"\"Test GithubOAuthProvider with custom URLs.\"\"\"\n        # Need to set env vars before importing/instantiating since they're class-level\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n                \"OAUTH_GITHUB_AUTH_URL\": \"https://custom.github.com/oauth/authorize\",\n                \"OAUTH_GITHUB_TOKEN_URL\": \"https://custom.github.com/oauth/token\",\n                \"OAUTH_GITHUB_USER_INFO_URL\": \"https://custom.github.com/api/user\",\n            },\n            clear=False,\n        ):\n            # Re-import to get the updated class-level attributes\n            from importlib import reload\n\n            import chainlit.oauth_providers as oauth_module\n\n            reload(oauth_module)\n\n            provider = oauth_module.GithubOAuthProvider()\n\n            assert provider.authorize_url == \"https://custom.github.com/oauth/authorize\"\n            assert provider.token_url == \"https://custom.github.com/oauth/token\"\n            assert provider.user_info_url == \"https://custom.github.com/api/user\"\n\n    @pytest.mark.asyncio\n    async def test_github_get_raw_token_response_success(self):\n        \"\"\"Test GitHub get_raw_token_response with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.text = \"access_token=test_token&token_type=bearer\"\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                result = await provider.get_raw_token_response(\n                    \"test_code\", \"http://localhost\"\n                )\n\n                assert \"access_token\" in result\n                assert result[\"access_token\"][0] == \"test_token\"\n\n    @pytest.mark.asyncio\n    async def test_github_get_token_success(self):\n        \"\"\"Test GitHub get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.text = \"access_token=github_token_123&token_type=bearer\"\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\"test_code\", \"http://localhost\")\n\n                assert token == \"github_token_123\"\n\n    @pytest.mark.asyncio\n    async def test_github_get_token_missing_access_token(self):\n        \"\"\"Test GitHub get_token raises error when access_token is missing.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.text = \"error=invalid_grant\"\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                with pytest.raises(HTTPException) as exc_info:\n                    await provider.get_token(\"test_code\", \"http://localhost\")\n\n                assert exc_info.value.status_code == 400\n                assert ACCESS_TOKEN_MISSING in str(exc_info.value.detail)\n\n    @pytest.mark.asyncio\n    async def test_github_get_user_info_success(self):\n        \"\"\"Test GitHub get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            mock_user_response = Mock()\n            mock_user_response.json.return_value = {\n                \"login\": \"testuser\",\n                \"avatar_url\": \"https://github.com/avatar.png\",\n                \"email\": \"test@example.com\",\n            }\n            mock_user_response.raise_for_status = Mock()\n\n            mock_emails_response = Mock()\n            mock_emails_response.json.return_value = [\n                {\"email\": \"test@example.com\", \"primary\": True, \"verified\": True}\n            ]\n            mock_emails_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_get = AsyncMock(\n                    side_effect=[mock_user_response, mock_emails_response]\n                )\n                mock_client.return_value.__aenter__.return_value.get = mock_get\n\n                github_user, user = await provider.get_user_info(\"test_token\")\n\n                assert github_user[\"login\"] == \"testuser\"\n                assert \"emails\" in github_user\n                assert isinstance(user, User)\n                assert user.identifier == \"testuser\"\n                assert user.metadata[\"provider\"] == \"github\"\n                assert user.metadata[\"image\"] == \"https://github.com/avatar.png\"\n\n\nclass TestGoogleOAuthProvider:\n    \"\"\"Test suite for GoogleOAuthProvider.\"\"\"\n\n    def test_google_provider_initialization(self):\n        \"\"\"Test GoogleOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GOOGLE_CLIENT_ID\": \"google_client_id\",\n                \"OAUTH_GOOGLE_CLIENT_SECRET\": \"google_secret\",\n            },\n        ):\n            provider = GoogleOAuthProvider()\n\n            assert provider.id == \"google\"\n            assert provider.client_id == \"google_client_id\"\n            assert provider.client_secret == \"google_secret\"\n            assert (\n                provider.authorize_url == \"https://accounts.google.com/o/oauth2/v2/auth\"\n            )\n            assert \"scope\" in provider.authorize_params\n            assert \"userinfo.profile\" in provider.authorize_params[\"scope\"]\n\n    @pytest.mark.asyncio\n    async def test_google_get_token_success(self):\n        \"\"\"Test Google get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GOOGLE_CLIENT_ID\": \"google_id\",\n                \"OAUTH_GOOGLE_CLIENT_SECRET\": \"google_secret\",\n            },\n        ):\n            provider = GoogleOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"google_access_token\",\n                \"token_type\": \"Bearer\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"google_access_token\"\n\n    @pytest.mark.asyncio\n    async def test_google_get_user_info_success(self):\n        \"\"\"Test Google get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GOOGLE_CLIENT_ID\": \"google_id\",\n                \"OAUTH_GOOGLE_CLIENT_SECRET\": \"google_secret\",\n            },\n        ):\n            provider = GoogleOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"email\": \"user@gmail.com\",\n                \"name\": \"Test User\",\n                \"picture\": \"https://google.com/photo.jpg\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.get = AsyncMock(\n                    return_value=mock_response\n                )\n\n                google_user, user = await provider.get_user_info(\"test_token\")\n\n                assert google_user[\"email\"] == \"user@gmail.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"user@gmail.com\"\n                assert user.metadata[\"provider\"] == \"google\"\n                assert user.metadata[\"image\"] == \"https://google.com/photo.jpg\"\n\n\nclass TestAzureADOAuthProvider:\n    \"\"\"Test suite for AzureADOAuthProvider.\"\"\"\n\n    def test_azure_ad_provider_initialization(self):\n        \"\"\"Test AzureADOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_CLIENT_ID\": \"azure_client_id\",\n                \"OAUTH_AZURE_AD_CLIENT_SECRET\": \"azure_secret\",\n                \"OAUTH_AZURE_AD_TENANT_ID\": \"tenant_123\",\n            },\n        ):\n            provider = AzureADOAuthProvider()\n\n            assert provider.id == \"azure-ad\"\n            assert provider.client_id == \"azure_client_id\"\n            assert provider.client_secret == \"azure_secret\"\n            assert \"tenant\" in provider.authorize_params\n            assert provider.authorize_params[\"tenant\"] == \"tenant_123\"\n\n    def test_azure_ad_single_tenant_urls(self):\n        \"\"\"Test Azure AD uses tenant-specific URLs when single tenant enabled.\"\"\"\n        # Azure AD URLs are set at class definition time, need to reload module\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_CLIENT_ID\": \"azure_id\",\n                \"OAUTH_AZURE_AD_CLIENT_SECRET\": \"azure_secret\",\n                \"OAUTH_AZURE_AD_TENANT_ID\": \"tenant_abc\",\n                \"OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT\": \"true\",\n            },\n            clear=False,\n        ):\n            from importlib import reload\n\n            import chainlit.oauth_providers as oauth_module\n\n            reload(oauth_module)\n\n            provider = oauth_module.AzureADOAuthProvider()\n\n            assert \"tenant_abc\" in provider.authorize_url\n            assert \"tenant_abc\" in provider.token_url\n\n    @pytest.mark.asyncio\n    async def test_azure_ad_get_token_with_refresh_token(self):\n        \"\"\"Test Azure AD get_token stores refresh token.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_CLIENT_ID\": \"azure_id\",\n                \"OAUTH_AZURE_AD_CLIENT_SECRET\": \"azure_secret\",\n                \"OAUTH_AZURE_AD_TENANT_ID\": \"tenant_123\",\n            },\n        ):\n            provider = AzureADOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"azure_access_token\",\n                \"refresh_token\": \"azure_refresh_token\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"azure_access_token\"\n                assert provider._refresh_token == \"azure_refresh_token\"\n\n    @pytest.mark.asyncio\n    async def test_azure_ad_get_user_info_with_photo(self):\n        \"\"\"Test Azure AD get_user_info includes photo when available.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_CLIENT_ID\": \"azure_id\",\n                \"OAUTH_AZURE_AD_CLIENT_SECRET\": \"azure_secret\",\n                \"OAUTH_AZURE_AD_TENANT_ID\": \"tenant_123\",\n            },\n        ):\n            provider = AzureADOAuthProvider()\n            provider._refresh_token = \"refresh_token_123\"\n\n            mock_user_response = Mock()\n            mock_user_response.json.return_value = {\n                \"userPrincipalName\": \"user@company.com\",\n                \"displayName\": \"Test User\",\n            }\n            mock_user_response.raise_for_status = Mock()\n\n            mock_photo_response = Mock()\n            mock_photo_response.aread = AsyncMock(return_value=b\"photo_data\")\n            mock_photo_response.headers = {\"Content-Type\": \"image/jpeg\"}\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_get = AsyncMock(\n                    side_effect=[mock_user_response, mock_photo_response]\n                )\n                mock_client.return_value.__aenter__.return_value.get = mock_get\n\n                azure_user, user = await provider.get_user_info(\"test_token\")\n\n                assert azure_user[\"userPrincipalName\"] == \"user@company.com\"\n                assert \"image\" in azure_user\n                assert isinstance(user, User)\n                assert user.identifier == \"user@company.com\"\n                assert user.metadata[\"provider\"] == \"azure-ad\"\n                assert user.metadata[\"refresh_token\"] == \"refresh_token_123\"\n\n\nclass TestOktaOAuthProvider:\n    \"\"\"Test suite for OktaOAuthProvider.\"\"\"\n\n    def test_okta_provider_initialization(self):\n        \"\"\"Test OktaOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_OKTA_CLIENT_ID\": \"okta_client_id\",\n                \"OAUTH_OKTA_CLIENT_SECRET\": \"okta_secret\",\n                \"OAUTH_OKTA_DOMAIN\": \"dev-12345.okta.com\",\n            },\n            clear=False,\n        ):\n            from importlib import reload\n\n            import chainlit.oauth_providers as oauth_module\n\n            reload(oauth_module)\n\n            provider = oauth_module.OktaOAuthProvider()\n\n            assert provider.id == \"okta\"\n            assert provider.client_id == \"okta_client_id\"\n            assert \"dev-12345.okta.com\" in provider.authorize_url\n\n    def test_okta_authorization_server_path_default(self):\n        \"\"\"Test Okta uses default authorization server.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_OKTA_CLIENT_ID\": \"okta_id\",\n                \"OAUTH_OKTA_CLIENT_SECRET\": \"okta_secret\",\n                \"OAUTH_OKTA_DOMAIN\": \"dev-12345.okta.com\",\n            },\n        ):\n            provider = OktaOAuthProvider()\n\n            assert provider.get_authorization_server_path() == \"/default\"\n\n    def test_okta_authorization_server_path_custom(self):\n        \"\"\"Test Okta uses custom authorization server.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_OKTA_CLIENT_ID\": \"okta_id\",\n                \"OAUTH_OKTA_CLIENT_SECRET\": \"okta_secret\",\n                \"OAUTH_OKTA_DOMAIN\": \"dev-12345.okta.com\",\n                \"OAUTH_OKTA_AUTHORIZATION_SERVER_ID\": \"custom_server\",\n            },\n        ):\n            provider = OktaOAuthProvider()\n\n            assert provider.get_authorization_server_path() == \"/custom_server\"\n\n    def test_okta_authorization_server_path_false(self):\n        \"\"\"Test Okta with no authorization server.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_OKTA_CLIENT_ID\": \"okta_id\",\n                \"OAUTH_OKTA_CLIENT_SECRET\": \"okta_secret\",\n                \"OAUTH_OKTA_DOMAIN\": \"dev-12345.okta.com\",\n                \"OAUTH_OKTA_AUTHORIZATION_SERVER_ID\": \"false\",\n            },\n        ):\n            provider = OktaOAuthProvider()\n\n            assert provider.get_authorization_server_path() == \"\"\n\n\nclass TestAuth0OAuthProvider:\n    \"\"\"Test suite for Auth0OAuthProvider.\"\"\"\n\n    def test_auth0_provider_initialization(self):\n        \"\"\"Test Auth0OAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AUTH0_CLIENT_ID\": \"auth0_client_id\",\n                \"OAUTH_AUTH0_CLIENT_SECRET\": \"auth0_secret\",\n                \"OAUTH_AUTH0_DOMAIN\": \"dev-12345.auth0.com\",\n            },\n        ):\n            provider = Auth0OAuthProvider()\n\n            assert provider.id == \"auth0\"\n            assert provider.client_id == \"auth0_client_id\"\n            assert \"dev-12345.auth0.com\" in provider.domain\n\n    def test_auth0_with_original_domain(self):\n        \"\"\"Test Auth0 with separate original domain.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AUTH0_CLIENT_ID\": \"auth0_id\",\n                \"OAUTH_AUTH0_CLIENT_SECRET\": \"auth0_secret\",\n                \"OAUTH_AUTH0_DOMAIN\": \"custom.domain.com\",\n                \"OAUTH_AUTH0_ORIGINAL_DOMAIN\": \"dev-12345.auth0.com\",\n            },\n        ):\n            provider = Auth0OAuthProvider()\n\n            assert \"custom.domain.com\" in provider.domain\n            assert \"dev-12345.auth0.com\" in provider.original_domain\n\n\nclass TestGenericOAuthProvider:\n    \"\"\"Test suite for GenericOAuthProvider.\"\"\"\n\n    def test_generic_provider_initialization(self):\n        \"\"\"Test GenericOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GENERIC_CLIENT_ID\": \"generic_id\",\n                \"OAUTH_GENERIC_CLIENT_SECRET\": \"generic_secret\",\n                \"OAUTH_GENERIC_AUTH_URL\": \"https://auth.example.com/oauth/authorize\",\n                \"OAUTH_GENERIC_TOKEN_URL\": \"https://auth.example.com/oauth/token\",\n                \"OAUTH_GENERIC_USER_INFO_URL\": \"https://auth.example.com/oauth/userinfo\",\n                \"OAUTH_GENERIC_SCOPES\": \"openid profile email\",\n            },\n        ):\n            provider = GenericOAuthProvider()\n\n            assert provider.id == \"generic\"\n            assert provider.client_id == \"generic_id\"\n            assert provider.authorize_url == \"https://auth.example.com/oauth/authorize\"\n            assert provider.token_url == \"https://auth.example.com/oauth/token\"\n            assert provider.user_info_url == \"https://auth.example.com/oauth/userinfo\"\n\n    def test_generic_provider_custom_name(self):\n        \"\"\"Test GenericOAuthProvider with custom name.\"\"\"\n        # Generic provider id is set at class definition time\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GENERIC_NAME\": \"my-custom-provider\",\n                \"OAUTH_GENERIC_CLIENT_ID\": \"generic_id\",\n                \"OAUTH_GENERIC_CLIENT_SECRET\": \"generic_secret\",\n                \"OAUTH_GENERIC_AUTH_URL\": \"https://auth.example.com/oauth/authorize\",\n                \"OAUTH_GENERIC_TOKEN_URL\": \"https://auth.example.com/oauth/token\",\n                \"OAUTH_GENERIC_USER_INFO_URL\": \"https://auth.example.com/oauth/userinfo\",\n                \"OAUTH_GENERIC_SCOPES\": \"openid profile\",\n            },\n            clear=False,\n        ):\n            from importlib import reload\n\n            import chainlit.oauth_providers as oauth_module\n\n            reload(oauth_module)\n\n            provider = oauth_module.GenericOAuthProvider()\n\n            assert provider.id == \"my-custom-provider\"\n\n    def test_generic_provider_custom_user_identifier(self):\n        \"\"\"Test GenericOAuthProvider with custom user identifier field.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GENERIC_CLIENT_ID\": \"generic_id\",\n                \"OAUTH_GENERIC_CLIENT_SECRET\": \"generic_secret\",\n                \"OAUTH_GENERIC_AUTH_URL\": \"https://auth.example.com/oauth/authorize\",\n                \"OAUTH_GENERIC_TOKEN_URL\": \"https://auth.example.com/oauth/token\",\n                \"OAUTH_GENERIC_USER_INFO_URL\": \"https://auth.example.com/oauth/userinfo\",\n                \"OAUTH_GENERIC_SCOPES\": \"openid\",\n                \"OAUTH_GENERIC_USER_IDENTIFIER\": \"username\",\n            },\n        ):\n            provider = GenericOAuthProvider()\n\n            assert provider.user_identifier == \"username\"\n\n\nclass TestAzureADHybridOAuthProvider:\n    \"\"\"Test suite for AzureADHybridOAuthProvider.\"\"\"\n\n    def test_azure_ad_hybrid_provider_initialization(self):\n        \"\"\"Test AzureADHybridOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_ID\": \"hybrid_client_id\",\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET\": \"hybrid_secret\",\n                \"OAUTH_AZURE_AD_HYBRID_TENANT_ID\": \"tenant_456\",\n            },\n        ):\n            provider = AzureADHybridOAuthProvider()\n\n            assert provider.id == \"azure-ad-hybrid\"\n            assert provider.client_id == \"hybrid_client_id\"\n            assert provider.client_secret == \"hybrid_secret\"\n            assert \"tenant\" in provider.authorize_params\n            assert provider.authorize_params[\"tenant\"] == \"tenant_456\"\n            assert provider.authorize_params[\"response_type\"] == \"code id_token\"\n            assert provider.authorize_params[\"response_mode\"] == \"form_post\"\n            assert \"nonce\" in provider.authorize_params\n\n    @pytest.mark.asyncio\n    async def test_azure_ad_hybrid_get_token_success(self):\n        \"\"\"Test AzureADHybrid get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_ID\": \"hybrid_id\",\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET\": \"hybrid_secret\",\n                \"OAUTH_AZURE_AD_HYBRID_TENANT_ID\": \"tenant_789\",\n            },\n        ):\n            provider = AzureADHybridOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"hybrid_access_token\",\n                \"refresh_token\": \"hybrid_refresh_token\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"hybrid_access_token\"\n                assert provider._refresh_token == \"hybrid_refresh_token\"\n\n    @pytest.mark.asyncio\n    async def test_azure_ad_hybrid_get_user_info_success(self):\n        \"\"\"Test AzureADHybrid get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_ID\": \"hybrid_id\",\n                \"OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET\": \"hybrid_secret\",\n                \"OAUTH_AZURE_AD_HYBRID_TENANT_ID\": \"tenant_789\",\n            },\n        ):\n            provider = AzureADHybridOAuthProvider()\n            provider._refresh_token = \"refresh_token_hybrid\"\n\n            mock_user_response = Mock()\n            mock_user_response.json.return_value = {\n                \"userPrincipalName\": \"hybrid@company.com\",\n                \"displayName\": \"Hybrid User\",\n            }\n            mock_user_response.raise_for_status = Mock()\n\n            mock_photo_response = Mock()\n            mock_photo_response.aread = AsyncMock(return_value=b\"photo_bytes\")\n            mock_photo_response.headers = {\"Content-Type\": \"image/png\"}\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_get = AsyncMock(\n                    side_effect=[mock_user_response, mock_photo_response]\n                )\n                mock_client.return_value.__aenter__.return_value.get = mock_get\n\n                azure_user, user = await provider.get_user_info(\"test_token\")\n\n                assert azure_user[\"userPrincipalName\"] == \"hybrid@company.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"hybrid@company.com\"\n                assert user.metadata[\"provider\"] == \"azure-ad\"\n                assert user.metadata[\"refresh_token\"] == \"refresh_token_hybrid\"\n\n\nclass TestDescopeOAuthProvider:\n    \"\"\"Test suite for DescopeOAuthProvider.\"\"\"\n\n    def test_descope_provider_initialization(self):\n        \"\"\"Test DescopeOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_DESCOPE_CLIENT_ID\": \"descope_client_id\",\n                \"OAUTH_DESCOPE_CLIENT_SECRET\": \"descope_secret\",\n            },\n        ):\n            provider = DescopeOAuthProvider()\n\n            assert provider.id == \"descope\"\n            assert provider.client_id == \"descope_client_id\"\n            assert provider.client_secret == \"descope_secret\"\n            assert \"openid profile email\" in provider.authorize_params[\"scope\"]\n\n    @pytest.mark.asyncio\n    async def test_descope_get_token_success(self):\n        \"\"\"Test Descope get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_DESCOPE_CLIENT_ID\": \"descope_id\",\n                \"OAUTH_DESCOPE_CLIENT_SECRET\": \"descope_secret\",\n            },\n        ):\n            provider = DescopeOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"descope_access_token\",\n                \"token_type\": \"Bearer\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"descope_access_token\"\n\n    @pytest.mark.asyncio\n    async def test_descope_get_user_info_success(self):\n        \"\"\"Test Descope get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_DESCOPE_CLIENT_ID\": \"descope_id\",\n                \"OAUTH_DESCOPE_CLIENT_SECRET\": \"descope_secret\",\n            },\n        ):\n            provider = DescopeOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"email\": \"user@descope.com\",\n                \"name\": \"Descope User\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.get = AsyncMock(\n                    return_value=mock_response\n                )\n\n                descope_user, user = await provider.get_user_info(\"test_token\")\n\n                assert descope_user[\"email\"] == \"user@descope.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"user@descope.com\"\n                assert user.metadata[\"provider\"] == \"descope\"\n\n\nclass TestAWSCognitoOAuthProvider:\n    \"\"\"Test suite for AWSCognitoOAuthProvider.\"\"\"\n\n    def test_cognito_provider_initialization(self):\n        \"\"\"Test AWSCognitoOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_COGNITO_CLIENT_ID\": \"cognito_client_id\",\n                \"OAUTH_COGNITO_CLIENT_SECRET\": \"cognito_secret\",\n                \"OAUTH_COGNITO_DOMAIN\": \"my-app.auth.us-east-1.amazoncognito.com\",\n            },\n        ):\n            provider = AWSCognitoOAuthProvider()\n\n            assert provider.id == \"aws-cognito\"\n            assert provider.client_id == \"cognito_client_id\"\n            assert provider.client_secret == \"cognito_secret\"\n            assert \"openid profile email\" in provider.scopes\n\n    def test_cognito_provider_custom_scopes(self):\n        \"\"\"Test AWSCognitoOAuthProvider with custom scopes.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_COGNITO_CLIENT_ID\": \"cognito_id\",\n                \"OAUTH_COGNITO_CLIENT_SECRET\": \"cognito_secret\",\n                \"OAUTH_COGNITO_DOMAIN\": \"my-app.auth.us-east-1.amazoncognito.com\",\n                \"OAUTH_COGNITO_SCOPE\": \"openid email phone\",\n            },\n        ):\n            provider = AWSCognitoOAuthProvider()\n\n            assert provider.scopes == \"openid email phone\"\n            assert provider.authorize_params[\"scope\"] == \"openid email phone\"\n\n    @pytest.mark.asyncio\n    async def test_cognito_get_token_success(self):\n        \"\"\"Test Cognito get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_COGNITO_CLIENT_ID\": \"cognito_id\",\n                \"OAUTH_COGNITO_CLIENT_SECRET\": \"cognito_secret\",\n                \"OAUTH_COGNITO_DOMAIN\": \"my-app.auth.us-east-1.amazoncognito.com\",\n            },\n        ):\n            provider = AWSCognitoOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"cognito_access_token\",\n                \"token_type\": \"Bearer\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"cognito_access_token\"\n\n    @pytest.mark.asyncio\n    async def test_cognito_get_user_info_success(self):\n        \"\"\"Test Cognito get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_COGNITO_CLIENT_ID\": \"cognito_id\",\n                \"OAUTH_COGNITO_CLIENT_SECRET\": \"cognito_secret\",\n                \"OAUTH_COGNITO_DOMAIN\": \"my-app.auth.us-east-1.amazoncognito.com\",\n            },\n        ):\n            provider = AWSCognitoOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"email\": \"user@cognito.com\",\n                \"picture\": \"https://cognito.com/photo.jpg\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.get = AsyncMock(\n                    return_value=mock_response\n                )\n\n                cognito_user, user = await provider.get_user_info(\"test_token\")\n\n                assert cognito_user[\"email\"] == \"user@cognito.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"user@cognito.com\"\n                assert user.metadata[\"provider\"] == \"aws-cognito\"\n                assert user.metadata[\"image\"] == \"https://cognito.com/photo.jpg\"\n\n\nclass TestGitlabOAuthProvider:\n    \"\"\"Test suite for GitlabOAuthProvider.\"\"\"\n\n    def test_gitlab_provider_initialization(self):\n        \"\"\"Test GitlabOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITLAB_CLIENT_ID\": \"gitlab_client_id\",\n                \"OAUTH_GITLAB_CLIENT_SECRET\": \"gitlab_secret\",\n                \"OAUTH_GITLAB_DOMAIN\": \"gitlab.example.com\",\n            },\n        ):\n            provider = GitlabOAuthProvider()\n\n            assert provider.id == \"gitlab\"\n            assert provider.client_id == \"gitlab_client_id\"\n            assert provider.client_secret == \"gitlab_secret\"\n            assert \"gitlab.example.com\" in provider.domain\n            assert \"openid profile email\" in provider.authorize_params[\"scope\"]\n\n    def test_gitlab_provider_strips_trailing_slash(self):\n        \"\"\"Test GitlabOAuthProvider strips trailing slash from domain.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITLAB_CLIENT_ID\": \"gitlab_id\",\n                \"OAUTH_GITLAB_CLIENT_SECRET\": \"gitlab_secret\",\n                \"OAUTH_GITLAB_DOMAIN\": \"gitlab.example.com/\",\n            },\n        ):\n            provider = GitlabOAuthProvider()\n\n            assert not provider.domain.endswith(\"/\")\n\n    @pytest.mark.asyncio\n    async def test_gitlab_get_token_success(self):\n        \"\"\"Test Gitlab get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITLAB_CLIENT_ID\": \"gitlab_id\",\n                \"OAUTH_GITLAB_CLIENT_SECRET\": \"gitlab_secret\",\n                \"OAUTH_GITLAB_DOMAIN\": \"gitlab.example.com\",\n            },\n        ):\n            provider = GitlabOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"gitlab_access_token\",\n                \"token_type\": \"Bearer\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"gitlab_access_token\"\n\n    @pytest.mark.asyncio\n    async def test_gitlab_get_user_info_success(self):\n        \"\"\"Test Gitlab get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITLAB_CLIENT_ID\": \"gitlab_id\",\n                \"OAUTH_GITLAB_CLIENT_SECRET\": \"gitlab_secret\",\n                \"OAUTH_GITLAB_DOMAIN\": \"gitlab.example.com\",\n            },\n        ):\n            provider = GitlabOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"email\": \"user@gitlab.com\",\n                \"picture\": \"https://gitlab.com/avatar.png\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.get = AsyncMock(\n                    return_value=mock_response\n                )\n\n                gitlab_user, user = await provider.get_user_info(\"test_token\")\n\n                assert gitlab_user[\"email\"] == \"user@gitlab.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"user@gitlab.com\"\n                assert user.metadata[\"provider\"] == \"gitlab\"\n                assert user.metadata[\"image\"] == \"https://gitlab.com/avatar.png\"\n\n\nclass TestKeycloakOAuthProvider:\n    \"\"\"Test suite for KeycloakOAuthProvider.\"\"\"\n\n    def test_keycloak_provider_initialization(self):\n        \"\"\"Test KeycloakOAuthProvider initialization.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_KEYCLOAK_CLIENT_ID\": \"keycloak_client_id\",\n                \"OAUTH_KEYCLOAK_CLIENT_SECRET\": \"keycloak_secret\",\n                \"OAUTH_KEYCLOAK_REALM\": \"my-realm\",\n                \"OAUTH_KEYCLOAK_BASE_URL\": \"https://keycloak.example.com\",\n            },\n        ):\n            provider = KeycloakOAuthProvider()\n\n            assert provider.client_id == \"keycloak_client_id\"\n            assert provider.client_secret == \"keycloak_secret\"\n            assert provider.realm == \"my-realm\"\n            assert provider.base_url == \"https://keycloak.example.com\"\n            assert \"profile email openid\" in provider.authorize_params[\"scope\"]\n\n    def test_keycloak_provider_custom_name(self):\n        \"\"\"Test KeycloakOAuthProvider with custom name.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_KEYCLOAK_NAME\": \"my-keycloak\",\n                \"OAUTH_KEYCLOAK_CLIENT_ID\": \"keycloak_id\",\n                \"OAUTH_KEYCLOAK_CLIENT_SECRET\": \"keycloak_secret\",\n                \"OAUTH_KEYCLOAK_REALM\": \"my-realm\",\n                \"OAUTH_KEYCLOAK_BASE_URL\": \"https://keycloak.example.com\",\n            },\n            clear=False,\n        ):\n            from importlib import reload\n\n            import chainlit.oauth_providers as oauth_module\n\n            reload(oauth_module)\n\n            provider = oauth_module.KeycloakOAuthProvider()\n\n            assert provider.id == \"my-keycloak\"\n\n    @pytest.mark.asyncio\n    async def test_keycloak_get_token_success(self):\n        \"\"\"Test Keycloak get_token with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_KEYCLOAK_CLIENT_ID\": \"keycloak_id\",\n                \"OAUTH_KEYCLOAK_CLIENT_SECRET\": \"keycloak_secret\",\n                \"OAUTH_KEYCLOAK_REALM\": \"my-realm\",\n                \"OAUTH_KEYCLOAK_BASE_URL\": \"https://keycloak.example.com\",\n            },\n        ):\n            provider = KeycloakOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"access_token\": \"keycloak_access_token\",\n                \"refresh_token\": \"keycloak_refresh_token\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                token = await provider.get_token(\n                    \"auth_code\", \"http://localhost/callback\"\n                )\n\n                assert token == \"keycloak_access_token\"\n                assert provider.refresh_token == \"keycloak_refresh_token\"\n\n    @pytest.mark.asyncio\n    async def test_keycloak_get_user_info_success(self):\n        \"\"\"Test Keycloak get_user_info with successful response.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_KEYCLOAK_CLIENT_ID\": \"keycloak_id\",\n                \"OAUTH_KEYCLOAK_CLIENT_SECRET\": \"keycloak_secret\",\n                \"OAUTH_KEYCLOAK_REALM\": \"my-realm\",\n                \"OAUTH_KEYCLOAK_BASE_URL\": \"https://keycloak.example.com\",\n            },\n        ):\n            provider = KeycloakOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.json.return_value = {\n                \"email\": \"user@keycloak.com\",\n                \"name\": \"Keycloak User\",\n            }\n            mock_response.raise_for_status = Mock()\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.get = AsyncMock(\n                    return_value=mock_response\n                )\n\n                keycloak_user, user = await provider.get_user_info(\"test_token\")\n\n                assert keycloak_user[\"email\"] == \"user@keycloak.com\"\n                assert isinstance(user, User)\n                assert user.identifier == \"user@keycloak.com\"\n                assert user.metadata[\"provider\"] == \"keycloak\"\n\n\nclass TestHelperFunctions:\n    \"\"\"Test suite for helper functions.\"\"\"\n\n    def test_get_oauth_provider_returns_correct_provider(self):\n        \"\"\"Test get_oauth_provider returns the correct provider.\"\"\"\n        provider = get_oauth_provider(\"github\")\n\n        assert provider is not None\n        assert provider.id == \"github\"\n\n    def test_get_oauth_provider_returns_none_for_unknown(self):\n        \"\"\"Test get_oauth_provider returns None for unknown provider.\"\"\"\n        provider = get_oauth_provider(\"unknown_provider\")\n\n        assert provider is None\n\n    def test_get_configured_oauth_providers_empty_when_none_configured(self):\n        \"\"\"Test get_configured_oauth_providers returns empty list when none configured.\"\"\"\n        # Clear all OAuth environment variables\n        with patch.dict(os.environ, {}, clear=True):\n            configured = get_configured_oauth_providers()\n\n            assert configured == []\n\n    def test_get_configured_oauth_providers_returns_configured(self):\n        \"\"\"Test get_configured_oauth_providers returns configured providers.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"github_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"github_secret\",\n                \"OAUTH_GOOGLE_CLIENT_ID\": \"google_id\",\n                \"OAUTH_GOOGLE_CLIENT_SECRET\": \"google_secret\",\n            },\n        ):\n            configured = get_configured_oauth_providers()\n\n            assert \"github\" in configured\n            assert \"google\" in configured\n\n\nclass TestOAuthProviderEdgeCases:\n    \"\"\"Test suite for OAuth provider edge cases.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_provider_handles_http_error(self):\n        \"\"\"Test provider handles HTTP errors gracefully.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GITHUB_CLIENT_ID\": \"test_id\",\n                \"OAUTH_GITHUB_CLIENT_SECRET\": \"test_secret\",\n            },\n        ):\n            provider = GithubOAuthProvider()\n\n            mock_response = Mock()\n            mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(\n                \"Error\", request=Mock(), response=Mock()\n            )\n\n            with patch(\"httpx.AsyncClient\") as mock_client:\n                mock_client.return_value.__aenter__.return_value.post = AsyncMock(\n                    return_value=mock_response\n                )\n\n                with pytest.raises(httpx.HTTPStatusError):\n                    await provider.get_raw_token_response(\"code\", \"url\")\n\n    def test_provider_strips_trailing_slash_from_domain(self):\n        \"\"\"Test providers strip trailing slashes from domains.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_AUTH0_CLIENT_ID\": \"auth0_id\",\n                \"OAUTH_AUTH0_CLIENT_SECRET\": \"auth0_secret\",\n                \"OAUTH_AUTH0_DOMAIN\": \"dev-12345.auth0.com/\",\n            },\n        ):\n            provider = Auth0OAuthProvider()\n\n            assert not provider.domain.endswith(\"/\")\n\n    def test_provider_with_prompt_parameter(self):\n        \"\"\"Test provider includes prompt parameter when configured.\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"OAUTH_GOOGLE_CLIENT_ID\": \"google_id\",\n                \"OAUTH_GOOGLE_CLIENT_SECRET\": \"google_secret\",\n                \"OAUTH_GOOGLE_PROMPT\": \"consent\",\n            },\n        ):\n            provider = GoogleOAuthProvider()\n\n            assert \"prompt\" in provider.authorize_params\n            assert provider.authorize_params[\"prompt\"] == \"consent\"\n"
  },
  {
    "path": "backend/tests/test_server.py",
    "content": "import datetime\nimport os\nimport pathlib\nfrom pathlib import Path\nfrom typing import Callable\nfrom unittest.mock import AsyncMock, Mock, create_autospec, mock_open\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\nfrom chainlit.auth import get_current_user\nfrom chainlit.config import (\n    APP_ROOT,\n    ChainlitConfig,\n    SpontaneousFileUploadFeature,\n)\nfrom chainlit.server import app\nfrom chainlit.types import AskFileSpec\nfrom chainlit.user import PersistedUser\n\n\n@pytest.fixture\ndef test_client():\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_load_translation(test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch):\n    mock_method = Mock(return_value={\"key\": \"value\"})\n    monkeypatch.setattr(\"chainlit.config.ChainlitConfig.load_translation\", mock_method)\n\n    return mock_method\n\n\ndef test_project_translations_default_language(\n    test_client: TestClient, mock_load_translation: Mock\n):\n    \"\"\"Test with default language.\"\"\"\n    response = test_client.get(\"/project/translations\")\n    assert response.status_code == 200\n    assert \"translation\" in response.json()\n    mock_load_translation.assert_called_once_with(\"en-US\")\n    mock_load_translation.reset_mock()\n\n\ndef test_project_translations_specific_language(\n    test_client: TestClient, mock_load_translation: Mock\n):\n    \"\"\"Test with a specific language.\"\"\"\n\n    response = test_client.get(\"/project/translations?language=fr-FR\")\n    assert response.status_code == 200\n    assert \"translation\" in response.json()\n    mock_load_translation.assert_called_once_with(\"fr-FR\")\n    mock_load_translation.reset_mock()\n\n\ndef test_project_translations_invalid_language(\n    test_client: TestClient, mock_load_translation: Mock\n):\n    \"\"\"Test with an invalid language.\"\"\"\n\n    response = test_client.get(\"/project/translations?language=invalid\")\n    assert response.status_code == 422\n\n    assert (\n        \"translation\" not in response.json()\n    )  # It should fall back to default translation\n    assert not mock_load_translation.called\n\n\ndef test_project_translations_bcp47_language(\n    test_client: TestClient, mock_load_translation: Mock\n):\n    \"\"\"Regression test for https://github.com/Chainlit/chainlit/issues/1352.\"\"\"\n\n    response = test_client.get(\"/project/translations?language=es-419\")\n    assert response.status_code == 200\n    assert \"translation\" in response.json()\n    mock_load_translation.assert_called_once_with(\"es-419\")\n    mock_load_translation.reset_mock()\n\n\n@pytest.fixture\ndef mock_get_current_user():\n    \"\"\"Override get_current_user() dependency.\"\"\"\n\n    # Programming sucks!\n    # Ref: https://github.com/fastapi/fastapi/issues/3331#issuecomment-1182452859\n    app.dependency_overrides[get_current_user] = create_autospec(lambda: None)\n\n    yield app.dependency_overrides[get_current_user]\n\n    del app.dependency_overrides[get_current_user]\n\n\nasync def test_project_settings(test_client: TestClient, mock_get_current_user: Mock):\n    \"\"\"Burn test for project settings.\"\"\"\n    response = test_client.get(\n        \"/project/settings\",\n    )\n\n    mock_get_current_user.assert_called_once()\n\n    assert response.status_code == 200, response.json()\n    data = response.json()\n\n    assert \"ui\" in data\n    assert \"features\" in data\n    assert \"userEnv\" in data\n    assert \"dataPersistence\" in data\n    assert \"threadResumable\" in data\n    assert \"markdown\" in data\n    assert \"debugUrl\" in data\n    assert data[\"chatProfiles\"] == []\n    assert data[\"starters\"] == []\n\n\ndef test_project_settings_path_traversal(\n    test_client: TestClient,\n    mock_get_current_user: Mock,\n    tmp_path: Path,\n    test_config: ChainlitConfig,\n):\n    \"\"\"Test to prevent path traversal in project settings.\"\"\"\n\n    # Create a mock chainlit directory structure\n    app_dir = tmp_path / \"app\"\n    app_dir.mkdir()\n    (tmp_path / \"README.md\").write_text(\"This is a secret README\")\n\n    # This is required for the exploit to occur.\n    chainlit_dir = app_dir / \"chainlit_stuff\"\n    chainlit_dir.mkdir()\n\n    # Mock the config root\n    test_config.root = str(app_dir)\n\n    # Attempt to access the file using path traversal\n    response = test_client.get(\n        \"/project/settings\", params={\"language\": \"stuff/../../README\"}\n    )\n\n    # Should not be able to read the file\n    assert \"This is a secret README\" not in response.text\n\n    assert response.status_code == 422\n\n    # The response should not contain the normally expected keys\n    data = response.json()\n    assert \"ui\" not in data\n    assert \"features\" not in data\n    assert \"userEnv\" not in data\n    assert \"dataPersistence\" not in data\n    assert \"threadResumable\" not in data\n    assert \"markdown\" not in data\n    assert \"chatProfiles\" not in data\n    assert \"starters\" not in data\n    assert \"debugUrl\" not in data\n\n\ndef test_get_avatar_default(test_client: TestClient, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Test with default avatar.\"\"\"\n    response = test_client.get(\"/avatars/default\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"image/\")\n\n\ndef test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPatch):\n    \"\"\"Test with custom avatar.\"\"\"\n    custom_avatar_path = os.path.join(\n        APP_ROOT, \"public\", \"avatars\", \"custom_avatar.png\"\n    )\n    os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True)\n    with open(custom_avatar_path, \"wb\") as f:\n        f.write(b\"fake image data\")\n\n    response = test_client.get(\"/avatars/custom_avatar\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"image/\")\n    assert response.content == b\"fake image data\"\n\n    # Clean up\n    os.remove(custom_avatar_path)\n\n\ndef test_get_avatar_with_spaces(\n    test_client: TestClient, monkeypatch: pytest.MonkeyPatch\n):\n    \"\"\"Test with custom avatar.\"\"\"\n    custom_avatar_path = os.path.join(APP_ROOT, \"public\", \"avatars\", \"my_assistant.png\")\n    os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True)\n    with open(custom_avatar_path, \"wb\") as f:\n        f.write(b\"fake image data\")\n\n    response = test_client.get(\"/avatars/My Assistant\")\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"image/\")\n    assert response.content == b\"fake image data\"\n\n    # Clean up\n    os.remove(custom_avatar_path)\n\n\ndef test_get_avatar_non_existent_favicon(\n    test_client: TestClient, monkeypatch: pytest.MonkeyPatch\n):\n    \"\"\"Test with non-existent avatar (should return favicon).\"\"\"\n    favicon_response = test_client.get(\"/favicon\")\n    assert favicon_response.status_code == 200\n\n    response = test_client.get(\"/avatars/non_existent\")\n\n    assert response.status_code == 200\n    assert response.headers[\"content-type\"].startswith(\"image/\")\n    assert response.content == favicon_response.content\n\n\ndef test_avatar_path_traversal(\n    test_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path\n):\n    \"\"\"Test to prevent potential path traversal in avatar route on Windows.\"\"\"\n\n    # Create a Mock object for the glob function\n    mock_glob = Mock(return_value=[])\n    monkeypatch.setattr(\"chainlit.server.glob.glob\", mock_glob)\n\n    mock_open_inst = mock_open(read_data=b'{\"should_not\": \"Be readable.\"}')\n    monkeypatch.setattr(\"builtins.open\", mock_open_inst)\n\n    # Attempt to access a file using path traversal\n    response = test_client.get(\"/avatars/..%5C..%5Capp\")\n\n    # No glob should ever be called\n    assert not mock_glob.called\n\n    # Should return an error status\n    assert response.status_code == 400\n\n\n@pytest.fixture\ndef mock_session_get_by_id_patched(mock_session: Mock, monkeypatch: pytest.MonkeyPatch):\n    test_session_id = \"test_session_id\"\n\n    # Mock the WebsocketSession.get_by_id method to return the mock session\n    monkeypatch.setattr(\n        \"chainlit.session.WebsocketSession.get_by_id\",\n        lambda session_id: mock_session if session_id == test_session_id else None,\n    )\n\n    return mock_session\n\n\ndef test_get_file_success(\n    test_client: TestClient,\n    mock_session_get_by_id_patched: Mock,\n    tmp_path: pathlib.Path,\n    mock_get_current_user: Mock,\n):\n    \"\"\"\n    Test successful retrieval of a file from a session.\n    \"\"\"\n    # Set current_user to match session.user\n    mock_get_current_user.return_value = mock_session_get_by_id_patched.user\n\n    # Create test data\n    test_content = b\"Test file content\"\n    test_file_id = \"test_file_id\"\n\n    # Create a temporary file with the test content\n    test_file = tmp_path / \"test_file\"\n    test_file.write_bytes(test_content)\n\n    mock_session_get_by_id_patched.files = {\n        test_file_id: {\n            \"id\": test_file_id,\n            \"path\": test_file,\n            \"name\": \"test.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(test_content),\n        }\n    }\n\n    # Make the GET request to retrieve the file\n    response = test_client.get(\n        f\"/project/file/{test_file_id}?session_id={mock_session_get_by_id_patched.id}\"\n    )\n\n    # Verify the response\n    assert response.status_code == 200\n    assert response.content == test_content\n    assert response.headers[\"content-type\"].startswith(\"text/plain\")\n\n\ndef test_get_file_not_existent_file(\n    test_client: TestClient,\n    mock_session_get_by_id_patched: Mock,\n    mock_get_current_user: Mock,\n):\n    \"\"\"\n    Test retrieval of a non-existing file from a session.\n    \"\"\"\n    # Set current_user to match session.user\n    mock_get_current_user.return_value = mock_session_get_by_id_patched.user\n\n    # Make the GET request to retrieve the file\n    response = test_client.get(\"/project/file/test_file_id?session_id=test_session_id\")\n\n    # Verify the response\n    assert response.status_code == 404\n\n\ndef test_get_file_non_existing_session(\n    test_client: TestClient,\n    tmp_path: pathlib.Path,\n    mock_session_get_by_id_patched: Mock,\n    mock_session: Mock,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    \"\"\"\n    Test that an unauthenticated user cannot retrieve a file uploaded by an authenticated user.\n    \"\"\"\n\n    # Attempt to access the file without authentication by providing an invalid session_id\n    response = test_client.get(\n        \"/project/file/nonexistent?session_id=unauthenticated_session_id\"\n    )\n\n    # Verify the response\n    assert response.status_code == 401  # Unauthorized\n\n\ndef test_upload_file_success(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n):\n    \"\"\"Test successful file upload.\"\"\"\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Mock the persist_file method to return a known value\n    expected_file_id = \"mocked_file_id\"\n    mock_session_get_by_id_patched.persist_file = AsyncMock(\n        return_value={\n            \"id\": expected_file_id,\n            \"name\": \"test_upload.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(file_content),\n        }\n    )\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    # Verify the response\n    assert response.status_code == 200\n    response_data = response.json()\n    assert \"id\" in response_data\n    assert response_data[\"id\"] == expected_file_id\n    assert response_data[\"name\"] == \"test_upload.txt\"\n    assert response_data[\"type\"] == \"text/plain\"\n    assert response_data[\"size\"] == len(file_content)\n\n    # Verify that persist_file was called with the correct arguments\n    mock_session_get_by_id_patched.persist_file.assert_called_once_with(\n        name=\"test_upload.txt\", content=file_content, mime=\"text/plain\"\n    )\n\n\ndef test_file_access_by_different_user(\n    test_client: TestClient,\n    mock_session_get_by_id_patched: Mock,\n    persisted_test_user: PersistedUser,\n    tmp_path: pathlib.Path,\n    mock_session_factory: Callable[..., Mock],\n):\n    \"\"\"Test that a file uploaded by one user cannot be accessed by another user.\"\"\"\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Mock the persist_file method to return a known value\n    expected_file_id = \"mocked_file_id\"\n    mock_session_get_by_id_patched.persist_file = AsyncMock(\n        return_value={\n            \"id\": expected_file_id,\n            \"name\": \"test_upload.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(file_content),\n        }\n    )\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    # Verify the response\n    assert response.status_code == 200\n\n    response_data = response.json()\n    assert \"id\" in response_data\n    file_id = response_data[\"id\"]\n\n    # Create a second session with a different user\n    second_session = mock_session_factory(\n        id=\"another_session_id\",\n        user=PersistedUser(\n            id=\"another_user_id\",\n            createdAt=datetime.datetime.now().isoformat(),\n            identifier=\"another_user_identifier\",\n        ),\n    )\n\n    # Attempt to access the uploaded file using the second user's session\n    response = test_client.get(\n        f\"/project/file/{file_id}?session_id={second_session.id}\"\n    )\n\n    # Verify that the access attempt fails\n    assert response.status_code == 401  # Unauthorized\n\n\ndef test_upload_file_missing_file(\n    test_client: TestClient,\n    mock_session: Mock,\n):\n    \"\"\"Test file upload with missing file in the request.\"\"\"\n\n    # Make the POST request without a file\n    response = test_client.post(\n        \"/project/file\",\n        data={\"session_id\": mock_session.id},\n    )\n\n    # Verify the response\n    assert response.status_code == 422  # Unprocessable Entity\n    assert \"detail\" in response.json()\n\n\ndef test_upload_file_invalid_session(\n    test_client: TestClient,\n):\n    \"\"\"Test file upload with an invalid session.\"\"\"\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Make the POST request with an invalid session_id\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        data={\"session_id\": \"invalid_session_id\"},\n    )\n\n    # Verify the response\n    assert response.status_code == 422\n\n\ndef test_upload_file_unauthorized(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n):\n    \"\"\"Test file upload without proper authorization.\"\"\"\n\n    # Mock the upload_file_session to have no user\n    mock_session_get_by_id_patched.user = None\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        data={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    assert response.status_code == 422\n\n\ndef test_upload_file_disabled(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    \"\"\"Test file upload being disabled by config.\"\"\"\n\n    # Set accept in config\n    monkeypatch.setattr(\n        test_config.features,\n        \"spontaneous_file_upload\",\n        SpontaneousFileUploadFeature(enabled=False),\n    )\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    # Verify the response\n    assert response.status_code == 400\n\n\n@pytest.mark.parametrize(\n    (\"accept_pattern\", \"mime_type\", \"expected_status\"),\n    [\n        ({\"image/*\": [\".png\", \".gif\", \".jpeg\", \".jpg\"]}, \"image/jpeg\", 400),\n        ([\"image/*\"], \"text/plain\", 400),\n        ([\"image/*\", \"application/*\"], \"text/plain\", 400),\n        ([\"image/png\", \"application/pdf\"], \"image/jpeg\", 400),\n        ([\"text/*\"], \"text/plain\", 200),\n        ([\"application/*\"], \"application/pdf\", 200),\n        ([\"image/*\"], \"image/jpeg\", 200),\n        ([\"image/*\", \"text/*\"], \"text/plain\", 200),\n        ([\"*/*\"], \"text/plain\", 200),\n        ([\"*/*\"], \"image/jpeg\", 200),\n        ([\"*/*\"], \"application/pdf\", 200),\n        ([\"image/*\", \"application/*\"], \"application/pdf\", 200),\n        ([\"image/*\", \"application/*\"], \"image/jpeg\", 200),\n        ([\"image/png\", \"application/pdf\"], \"image/png\", 200),\n        ([\"image/png\", \"application/pdf\"], \"application/pdf\", 200),\n        ({\"image/*\": []}, \"image/jpeg\", 200),\n        (\n            {\"image/*\": [\".png\", \".gif\", \".jpeg\", \".jpg\"]},\n            \"text/plain\",\n            400,\n        ),  # mime type not allowed\n        (\n            {\"*/*\": [\".txt\", \".gif\", \".jpeg\", \".jpg\"]},\n            \"text/plain\",\n            200,\n        ),  # extension allowed\n        (\n            {\"*/*\": [\".gif\", \".jpeg\", \".jpg\"]},\n            \"text/plain\",\n            400,\n        ),  # extension not allowed\n    ],\n)\ndef test_upload_file_mime_type_check(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n    monkeypatch: pytest.MonkeyPatch,\n    accept_pattern: list[str],\n    mime_type: str,\n    expected_status: int,\n):\n    \"\"\"Test check of mime_type.\"\"\"\n\n    # Set accept in config\n    monkeypatch.setattr(\n        test_config.features,\n        \"spontaneous_file_upload\",\n        SpontaneousFileUploadFeature(enabled=True, accept=accept_pattern),\n    )\n\n    # Prepare the files to upload\n    file_content = b\"Sample file content\"\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, mime_type),\n    }\n\n    # Mock the persist_file method to return a known value\n    expected_file_id = \"mocked_file_id\"\n    mock_session_get_by_id_patched.persist_file = AsyncMock(\n        return_value={\n            \"id\": expected_file_id,\n            \"name\": \"test_upload.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(file_content),\n        }\n    )\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    # Verify the response\n    assert response.status_code == expected_status\n\n\n@pytest.mark.parametrize(\n    (\"file_content\", \"content_multiplier\", \"max_size_mb\", \"expected_status\"),\n    [\n        (b\"1\", 1, 1, 200),\n        (b\"11\", 1024 * 1024, 1, 400),\n    ],\n)\ndef test_upload_file_size_check(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n    monkeypatch: pytest.MonkeyPatch,\n    file_content: bytes,\n    content_multiplier: int,\n    max_size_mb: int,\n    expected_status: int,\n):\n    \"\"\"Test check of max_size_mb.\"\"\"\n\n    file_content = file_content * content_multiplier\n\n    # Set accept in config\n    monkeypatch.setattr(\n        test_config.features,\n        \"spontaneous_file_upload\",\n        SpontaneousFileUploadFeature(max_size_mb=max_size_mb, enabled=True),\n    )\n\n    # Prepare the files to upload\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    # Mock the persist_file method to return a known value\n    expected_file_id = \"mocked_file_id\"\n    mock_session_get_by_id_patched.persist_file = AsyncMock(\n        return_value={\n            \"id\": expected_file_id,\n            \"name\": \"test_upload.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(file_content),\n        }\n    )\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\"session_id\": mock_session_get_by_id_patched.id},\n    )\n\n    # Verify the response\n    assert response.status_code == expected_status\n\n\n@pytest.mark.parametrize(\n    (\n        \"file_content\",\n        \"content_multiplier\",\n        \"max_size_mb\",\n        \"parent_id\",\n        \"expected_status\",\n        \"accept\",\n    ),\n    [\n        (b\"1\", 1, 1, \"mocked_parent_id\", 200, [\"text/plain\"]),\n        (b\"11\", 1024 * 1024, 1, \"mocked_parent_id\", 400, [\"text/plain\"]),\n        (b\"11\", 1, 1, \"invalid_parent_id\", 404, [\"text/plain\"]),\n        (b\"11\", 1, 1, \"mocked_parent_id\", 400, [\"image/gif\"]),\n    ],\n)\ndef test_ask_file_with_spontaneous_upload_disabled(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    mock_session_get_by_id_patched: Mock,\n    monkeypatch: pytest.MonkeyPatch,\n    file_content: bytes,\n    content_multiplier: int,\n    max_size_mb: int,\n    parent_id: str,\n    expected_status: int,\n    accept: list[str],\n):\n    \"\"\"Test file upload being disabled by config.\"\"\"\n\n    # Set accept in config\n    monkeypatch.setattr(\n        test_config.features,\n        \"spontaneous_file_upload\",\n        SpontaneousFileUploadFeature(enabled=False),\n    )\n\n    # Prepare the files to upload\n    file_content = file_content * content_multiplier\n    files = {\n        \"file\": (\"test_upload.txt\", file_content, \"text/plain\"),\n    }\n\n    expected_file_id = \"mocked_file_id\"\n    mock_session_get_by_id_patched.persist_file = AsyncMock(\n        return_value={\n            \"id\": expected_file_id,\n            \"name\": \"test_upload.txt\",\n            \"type\": \"text/plain\",\n            \"size\": len(file_content),\n        }\n    )\n\n    mock_session_get_by_id_patched.files_spec = {\n        \"mocked_parent_id\": AskFileSpec(\n            step_id=\"mocked_file_spec\",\n            timeout=1,\n            type=\"file\",\n            accept=accept,\n            max_files=1,\n            max_size_mb=max_size_mb,\n        )\n    }\n\n    # Make the POST request to upload the file\n    response = test_client.post(\n        \"/project/file\",\n        files=files,\n        params={\n            \"session_id\": mock_session_get_by_id_patched.id,\n            \"ask_parent_id\": parent_id,\n        },\n    )\n\n    # Verify the response\n    assert response.status_code == expected_status\n\n\ndef test_project_translations_file_path_traversal(\n    test_client: TestClient, monkeypatch: pytest.MonkeyPatch\n):\n    \"\"\"Test to prevent file path traversal in project translations.\"\"\"\n\n    mock_open_inst = mock_open(read_data='{\"should_not\": \"Be readable.\"}')\n    monkeypatch.setattr(\"builtins.open\", mock_open_inst)\n\n    # Attempt to access the file using path traversal\n    response = test_client.get(\n        \"/project/translations\", params={\"language\": \"/app/unreadable\"}\n    )\n\n    # File should never be opened\n    assert not mock_open_inst.called\n\n    # Should give error status\n    assert response.status_code == 422\n\n\ndef test_project_settings_with_chat_profile_config_overrides(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    \"\"\"Test that /project/settings endpoint returns merged configuration when chat_profile is specified.\"\"\"\n    from chainlit.config import (\n        ChainlitConfigOverrides,\n        FeaturesSettings,\n        McpFeature,\n        UISettings,\n    )\n    from chainlit.types import ChatProfile\n\n    # Mock chat profiles with different config overrides\n    mock_profiles = [\n        ChatProfile(\n            name=\"basic\",\n            markdown_description=\"Basic profile without overrides\",\n            default=True,\n        ),\n        ChatProfile(\n            name=\"mcp-enabled\",\n            markdown_description=\"Profile with MCP enabled\",\n            config_overrides=ChainlitConfigOverrides(\n                features=FeaturesSettings(mcp=McpFeature(enabled=True)),\n                ui=UISettings(name=\"MCP Assistant\", default_theme=\"dark\"),\n            ),\n        ),\n        ChatProfile(\n            name=\"light-theme\",\n            markdown_description=\"Profile with light theme\",\n            config_overrides=ChainlitConfigOverrides(\n                ui=UISettings(\n                    name=\"Light Theme App\",\n                    default_theme=\"light\",\n                    description=\"Light theme app\",\n                )\n            ),\n        ),\n    ]\n\n    # Mock the chat profiles callback\n    async def mock_get_chat_profiles(user, language):\n        # Use asyncio.sleep to make this truly async\n        import asyncio\n\n        await asyncio.sleep(0)\n        return mock_profiles\n\n    test_config.code.set_chat_profiles = mock_get_chat_profiles\n\n    # Test 1: Default profile (no overrides)\n    response = test_client.get(\"/project/settings\", params={\"chat_profile\": \"basic\"})\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Should return base configuration without overrides\n    assert config_data[\"ui\"][\"name\"] == test_config.ui.name  # Original name\n    assert (\n        config_data[\"features\"][\"mcp\"][\"enabled\"] == test_config.features.mcp.enabled\n    )  # Original MCP setting\n\n    # Test 2: MCP-enabled profile\n    response = test_client.get(\n        \"/project/settings\", params={\"chat_profile\": \"mcp-enabled\"}\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Should return merged configuration with MCP enabled and custom UI\n    assert config_data[\"features\"][\"mcp\"][\"enabled\"] is True  # Overridden\n    assert config_data[\"ui\"][\"name\"] == \"MCP Assistant\"  # Overridden\n    assert config_data[\"ui\"][\"default_theme\"] == \"dark\"  # Overridden\n\n    # Test 3: Light theme profile\n    response = test_client.get(\n        \"/project/settings\", params={\"chat_profile\": \"light-theme\"}\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Should return merged configuration with light theme\n    assert config_data[\"ui\"][\"default_theme\"] == \"light\"  # Overridden\n    assert config_data[\"ui\"][\"description\"] == \"Light theme app\"  # Overridden\n    assert (\n        config_data[\"features\"][\"mcp\"][\"enabled\"] == test_config.features.mcp.enabled\n    )  # Not overridden\n\n    # Test 4: Non-existent profile (should return base config)\n    response = test_client.get(\n        \"/project/settings\", params={\"chat_profile\": \"non-existent\"}\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Should return base configuration\n    assert config_data[\"ui\"][\"name\"] == test_config.ui.name\n    assert config_data[\"features\"][\"mcp\"][\"enabled\"] == test_config.features.mcp.enabled\n\n    # Test 5: No profile specified (should return base config)\n    response = test_client.get(\"/project/settings\")\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Should return base configuration\n    assert config_data[\"ui\"][\"name\"] == test_config.ui.name\n    assert config_data[\"features\"][\"mcp\"][\"enabled\"] == test_config.features.mcp.enabled\n\n\ndef test_project_settings_config_overrides_serialization(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    \"\"\"Test that config_overrides field is not included in serialized chat profiles.\"\"\"\n    from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature\n    from chainlit.types import ChatProfile\n\n    # Mock chat profile with config overrides\n    mock_profile = ChatProfile(\n        name=\"test-profile\",\n        markdown_description=\"Test profile\",\n        config_overrides=ChainlitConfigOverrides(\n            features=FeaturesSettings(mcp=McpFeature(enabled=True))\n        ),\n    )\n\n    async def mock_get_chat_profiles(user, language):\n        # Use asyncio.sleep to make this truly async\n        import asyncio\n\n        await asyncio.sleep(0)\n        return [mock_profile]\n\n    test_config.code.set_chat_profiles = mock_get_chat_profiles\n\n    # Get the project settings\n    response = test_client.get(\n        \"/project/settings\", params={\"chat_profile\": \"test-profile\"}\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Check that chatProfiles are included in the response\n    assert \"chatProfiles\" in config_data\n    assert len(config_data[\"chatProfiles\"]) == 1\n\n    # Check that config_overrides is NOT included in the serialized profile\n    profile_data = config_data[\"chatProfiles\"][0]\n    assert \"config_overrides\" not in profile_data\n    assert profile_data[\"name\"] == \"test-profile\"\n    assert profile_data[\"markdown_description\"] == \"Test profile\"\n\n\ndef test_project_settings_config_overrides_language(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    \"\"\"Test that localized chat profiles use the right config overrides.\"\"\"\n    from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature\n    from chainlit.types import ChatProfile\n\n    # Mock chat profile with config overrides\n    mock_profile_fr = ChatProfile(\n        name=\"test-profile-fr\",\n        markdown_description=\"Test profil\",\n        config_overrides=ChainlitConfigOverrides(\n            features=FeaturesSettings(mcp=McpFeature(enabled=True))\n        ),\n    )\n\n    mock_profile_en = ChatProfile(\n        name=\"test-profile\",\n        markdown_description=\"Test profile\",\n        config_overrides=ChainlitConfigOverrides(\n            features=FeaturesSettings(mcp=McpFeature(enabled=False))\n        ),\n    )\n\n    async def mock_get_chat_profiles(user, language):\n        # Use asyncio.sleep to make this truly async\n        import asyncio\n\n        await asyncio.sleep(0)\n        if language == \"fr-CA\":\n            return [mock_profile_fr]\n\n        return [mock_profile_en]\n\n    test_config.code.set_chat_profiles = mock_get_chat_profiles\n\n    # Get the project settings in French\n    response = test_client.get(\n        \"/project/settings\",\n        params={\"language\": \"fr-CA\", \"chat_profile\": \"test-profile-fr\"},\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Check that chatProfiles are included in the response\n    assert \"chatProfiles\" in config_data\n    assert len(config_data[\"chatProfiles\"]) == 1\n\n    # Check that config_overrides is NOT included in the serialized profile\n    assert config_data[\"features\"][\"mcp\"][\"enabled\"] is True  # Overridden\n\n    # Check that the profile_data matches the selected profile.\n    profile_data = config_data[\"chatProfiles\"][0]\n    assert \"config_overrides\" not in profile_data\n    assert profile_data[\"name\"] == \"test-profile-fr\"\n    assert profile_data[\"markdown_description\"] == \"Test profil\"\n\n    # Get the project settings in English\n    response = test_client.get(\n        \"/project/settings\",\n        params={\"language\": \"en-US\", \"chat_profile\": \"test-profile\"},\n    )\n    assert response.status_code == 200\n    config_data = response.json()\n\n    # Check that chatProfiles are included in the response\n    assert \"chatProfiles\" in config_data\n    assert len(config_data[\"chatProfiles\"]) == 1\n\n    # Check that config_overrides is NOT included in the serialized profile\n    assert config_data[\"features\"][\"mcp\"][\"enabled\"] is False  # Overridden\n\n    # Check that the profile_data matches the selected profile.\n    profile_data = config_data[\"chatProfiles\"][0]\n    assert \"config_overrides\" not in profile_data\n    assert profile_data[\"name\"] == \"test-profile\"\n    assert profile_data[\"markdown_description\"] == \"Test profile\"\n\n\ndef test_project_settings_thread_sharing_flag(\n    test_client: TestClient,\n    test_config: ChainlitConfig,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    # Start with both disabled\n    test_config.features.allow_thread_sharing = False\n    test_config.code.on_shared_thread_view = None\n    resp = test_client.get(\"/project/settings\")\n    assert resp.status_code == 200\n    assert resp.json().get(\"threadSharing\") is False\n\n    # Enable flag only -> still False (callback missing)\n    test_config.features.allow_thread_sharing = True\n    test_config.code.on_shared_thread_view = None\n    resp = test_client.get(\"/project/settings\")\n    assert resp.status_code == 200\n    assert resp.json().get(\"threadSharing\") is False\n\n    # Enable callback only -> still False (flag disabled)\n    test_config.features.allow_thread_sharing = False\n\n    def dummy_cb(*args, **kwargs):\n        return True\n\n    test_config.code.on_shared_thread_view = dummy_cb\n    resp = test_client.get(\"/project/settings\")\n    assert resp.status_code == 200\n    assert resp.json().get(\"threadSharing\") is False\n\n    # Enable both -> True\n    test_config.features.allow_thread_sharing = True\n    test_config.code.on_shared_thread_view = dummy_cb\n    resp = test_client.get(\"/project/settings\")\n    assert resp.status_code == 200\n    assert resp.json().get(\"threadSharing\") is True\n\n\ndef test_share_thread_endpoint_sets_flags(\n    test_client: TestClient,\n    monkeypatch: pytest.MonkeyPatch,\n):\n    # Override current user to match thread author\n    from chainlit.server import app as _app, get_current_user as _get_current_user\n\n    author = PersistedUser(\n        id=\"u1\",\n        createdAt=datetime.datetime.now().isoformat(),\n        identifier=\"author\",\n    )\n\n    _app.dependency_overrides[_get_current_user] = lambda: author\n\n    # Mock data layer\n    from unittest.mock import AsyncMock\n\n    dl = AsyncMock()\n    dl.get_thread.return_value = {\n        \"id\": \"t1\",\n        \"name\": \"Thread 1\",\n        \"userIdentifier\": \"author\",\n        \"metadata\": {\"other\": True},\n    }\n    dl.get_thread_author.return_value = \"author\"\n    dl.build_debug_url.return_value = \"\"\n\n    # Ensure data layer is initialized for both server routes and ACL checks\n    import chainlit.data as data_mod\n\n    data_mod._data_layer = dl\n    data_mod._data_layer_initialized = True\n\n    # Share\n    r = test_client.put(\n        \"/project/thread/share\", json={\"threadId\": \"t1\", \"isShared\": True}\n    )\n    assert r.status_code == 200\n\n    # Validate metadata passed to update_thread includes is_shared and shared_at\n    assert dl.update_thread.await_count >= 1\n    _, kwargs = dl.update_thread.await_args\n    assert kwargs.get(\"thread_id\") == \"t1\"\n    meta = kwargs.get(\"metadata\") or {}\n    assert meta.get(\"is_shared\") is True\n    assert isinstance(meta.get(\"shared_at\"), str)\n\n    # Unshare\n    r = test_client.put(\n        \"/project/thread/share\", json={\"threadId\": \"t1\", \"isShared\": False}\n    )\n    assert r.status_code == 200\n    _, kwargs = dl.update_thread.await_args\n    meta = kwargs.get(\"metadata\") or {}\n    assert meta.get(\"is_shared\") is False\n    assert \"shared_at\" not in meta\n\n    # Cleanup override and data layer\n    del _app.dependency_overrides[_get_current_user]\n    data_mod._data_layer = None\n    data_mod._data_layer_initialized = False\n\n\ndef test_health_check(test_client: TestClient):\n    response = test_client.get(\"/health\")\n    assert response.status_code == 200\n    assert response.json() == {\"status\": \"ok\"}\n"
  },
  {
    "path": "backend/tests/test_session.py",
    "content": "import json\nimport tempfile\nimport uuid\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom chainlit.session import (\n    BaseSession,\n    HTTPSession,\n    JSONEncoderIgnoreNonSerializable,\n    WebsocketSession,\n    clean_metadata,\n)\n\n\nclass TestJSONEncoderIgnoreNonSerializable:\n    \"\"\"Test suite for JSONEncoderIgnoreNonSerializable.\"\"\"\n\n    def test_encoder_handles_serializable_objects(self):\n        \"\"\"Test that encoder handles normal serializable objects.\"\"\"\n        data = {\n            \"string\": \"value\",\n            \"number\": 42,\n            \"list\": [1, 2, 3],\n            \"dict\": {\"key\": \"value\"},\n        }\n        result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)\n        assert json.loads(result) == data\n\n    def test_encoder_ignores_non_serializable_objects(self):\n        \"\"\"Test that encoder returns None for non-serializable objects.\"\"\"\n\n        class NonSerializable:\n            pass\n\n        data = {\"normal\": \"value\", \"non_serializable\": NonSerializable()}\n        result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)\n        parsed = json.loads(result)\n\n        assert parsed[\"normal\"] == \"value\"\n        assert parsed[\"non_serializable\"] is None\n\n    def test_encoder_with_nested_non_serializable(self):\n        \"\"\"Test encoder with nested non-serializable objects.\"\"\"\n\n        class NonSerializable:\n            pass\n\n        data = {\n            \"level1\": {\n                \"level2\": {\n                    \"serializable\": \"value\",\n                    \"non_serializable\": NonSerializable(),\n                }\n            }\n        }\n        result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable)\n        parsed = json.loads(result)\n\n        assert parsed[\"level1\"][\"level2\"][\"serializable\"] == \"value\"\n        assert parsed[\"level1\"][\"level2\"][\"non_serializable\"] is None\n\n\nclass TestCleanMetadata:\n    \"\"\"Test suite for clean_metadata function.\"\"\"\n\n    def test_clean_metadata_with_normal_data(self):\n        \"\"\"Test clean_metadata with normal serializable data.\"\"\"\n        metadata = {\"key\": \"value\", \"number\": 42, \"list\": [1, 2, 3]}\n        result = clean_metadata(metadata)\n        assert result == metadata\n\n    def test_clean_metadata_removes_non_serializable(self):\n        \"\"\"Test that clean_metadata removes non-serializable objects.\"\"\"\n\n        class NonSerializable:\n            pass\n\n        metadata = {\"normal\": \"value\", \"non_serializable\": NonSerializable()}\n        result = clean_metadata(metadata)\n\n        assert result[\"normal\"] == \"value\"\n        assert result[\"non_serializable\"] is None\n\n    def test_clean_metadata_redacts_large_data(self):\n        \"\"\"Test that clean_metadata redacts data exceeding max size.\"\"\"\n        # Create large metadata\n        large_data = {\"data\": \"x\" * 2000000}  # > 1MB\n        result = clean_metadata(large_data, max_size=1048576)\n\n        assert \"message\" in result\n        assert \"exceeds the limit\" in result[\"message\"]\n\n    def test_clean_metadata_with_custom_max_size(self):\n        \"\"\"Test clean_metadata with custom max size.\"\"\"\n        small_data = {\"data\": \"x\" * 100}\n        result = clean_metadata(small_data, max_size=50)\n\n        # Should be redacted because it exceeds 50 bytes\n        assert \"message\" in result\n        assert \"exceeds the limit\" in result[\"message\"]\n\n    def test_clean_metadata_preserves_unicode(self):\n        \"\"\"Test that clean_metadata preserves Unicode characters.\"\"\"\n        metadata = {\"chinese\": \"你好\", \"emoji\": \"🎉\", \"japanese\": \"こんにちは\"}\n        result = clean_metadata(metadata)\n\n        assert result[\"chinese\"] == \"你好\"\n        assert result[\"emoji\"] == \"🎉\"\n        assert result[\"japanese\"] == \"こんにちは\"\n\n\nclass TestBaseSession:\n    \"\"\"Test suite for BaseSession class.\"\"\"\n\n    def test_base_session_initialization(self):\n        \"\"\"Test BaseSession initialization with required parameters.\"\"\"\n        session = BaseSession(\n            id=\"test_id\",\n            client_type=\"webapp\",\n            thread_id=None,\n            user=None,\n            token=None,\n            user_env=None,\n        )\n\n        assert session.id == \"test_id\"\n        assert session.client_type == \"webapp\"\n        assert session.thread_id is not None  # Auto-generated UUID\n        assert session.user is None\n        assert session.token is None\n        assert session.user_env == {}\n        assert session.chat_settings == {}\n\n    def test_base_session_with_thread_id(self):\n        \"\"\"Test BaseSession with provided thread_id.\"\"\"\n        thread_id = str(uuid.uuid4())\n        session = BaseSession(\n            id=\"test_id\",\n            client_type=\"webapp\",\n            thread_id=thread_id,\n            user=None,\n            token=None,\n            user_env=None,\n        )\n\n        assert session.thread_id == thread_id\n        assert session.thread_id_to_resume == thread_id\n\n    def test_base_session_with_user_env(self):\n        \"\"\"Test BaseSession with user environment variables.\"\"\"\n        user_env = {\"API_KEY\": \"secret\", \"ENV_VAR\": \"value\"}\n        session = BaseSession(\n            id=\"test_id\",\n            client_type=\"webapp\",\n            thread_id=None,\n            user=None,\n            token=None,\n            user_env=user_env,\n        )\n\n        assert session.user_env == user_env\n\n    def test_base_session_with_chat_profile(self):\n        \"\"\"Test BaseSession with chat profile.\"\"\"\n        session = BaseSession(\n            id=\"test_id\",\n            client_type=\"webapp\",\n            thread_id=None,\n            user=None,\n            token=None,\n            user_env=None,\n            chat_profile=\"gpt-4\",\n        )\n\n        assert session.chat_profile == \"gpt-4\"\n\n    def test_base_session_files_dir(self):\n        \"\"\"Test BaseSession files_dir property.\"\"\"\n        with patch(\"chainlit.config.FILES_DIRECTORY\", Path(\"/tmp/files\")):\n            session = BaseSession(\n                id=\"test_id\",\n                client_type=\"webapp\",\n                thread_id=None,\n                user=None,\n                token=None,\n                user_env=None,\n            )\n\n            assert session.files_dir == Path(\"/tmp/files/test_id\")\n\n    @pytest.mark.asyncio\n    async def test_base_session_persist_file_with_content(self):\n        \"\"\"Test persisting a file with content.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = BaseSession(\n                    id=\"test_id\",\n                    client_type=\"webapp\",\n                    thread_id=None,\n                    user=None,\n                    token=None,\n                    user_env=None,\n                )\n\n                content = b\"test file content\"\n                result = await session.persist_file(\n                    name=\"test.txt\",\n                    mime=\"text/plain\",\n                    content=content,\n                )\n\n                assert \"id\" in result\n                assert result[\"id\"] in session.files\n                assert session.files[result[\"id\"]][\"name\"] == \"test.txt\"\n                assert session.files[result[\"id\"]][\"type\"] == \"text/plain\"\n\n    @pytest.mark.asyncio\n    async def test_base_session_persist_file_with_string_content(self):\n        \"\"\"Test persisting a file with string content.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = BaseSession(\n                    id=\"test_id\",\n                    client_type=\"webapp\",\n                    thread_id=None,\n                    user=None,\n                    token=None,\n                    user_env=None,\n                )\n\n                content = \"test string content\"\n                result = await session.persist_file(\n                    name=\"test.txt\",\n                    mime=\"text/plain\",\n                    content=content,\n                )\n\n                assert \"id\" in result\n                file_id = result[\"id\"]\n                assert session.files[file_id][\"size\"] > 0\n\n    @pytest.mark.asyncio\n    async def test_base_session_persist_file_without_path_or_content(self):\n        \"\"\"Test that persist_file raises error without path or content.\"\"\"\n        session = BaseSession(\n            id=\"test_id\",\n            client_type=\"webapp\",\n            thread_id=None,\n            user=None,\n            token=None,\n            user_env=None,\n        )\n\n        with pytest.raises(ValueError, match=\"Either path or content must be provided\"):\n            await session.persist_file(name=\"test.txt\", mime=\"text/plain\")\n\n    def test_base_session_to_persistable(self):\n        \"\"\"Test BaseSession to_persistable method.\"\"\"\n        from chainlit.user_session import user_sessions\n\n        original_sessions = user_sessions.copy()\n        user_sessions.update({\"test_id\": {\"key\": \"value\"}})\n\n        try:\n            with patch(\"chainlit.config.config\") as mock_config:\n                mock_config.project.persist_user_env = True\n\n                session = BaseSession(\n                    id=\"test_id\",\n                    client_type=\"webapp\",\n                    thread_id=None,\n                    user=None,\n                    token=None,\n                    user_env={\"API_KEY\": \"secret\"},\n                    chat_profile=\"gpt-4\",\n                )\n                session.chat_settings = {\"temperature\": 0.7}\n\n                result = session.to_persistable()\n\n                assert result[\"chat_settings\"] == {\"temperature\": 0.7}\n                assert result[\"chat_profile\"] == \"gpt-4\"\n                assert result[\"client_type\"] == \"webapp\"\n        finally:\n            user_sessions.clear()\n            user_sessions.update(original_sessions)\n\n    def test_base_session_to_persistable_without_persist_user_env(self):\n        \"\"\"Test to_persistable removes user_env when persist_user_env is False.\"\"\"\n        from chainlit.user_session import user_sessions\n\n        original_sessions = user_sessions.copy()\n        user_sessions.update({\"test_id\": {\"env\": {\"KEY\": \"value\"}}})\n\n        try:\n            with patch(\"chainlit.config.config\") as mock_config:\n                mock_config.project.persist_user_env = False\n\n                session = BaseSession(\n                    id=\"test_id\",\n                    client_type=\"webapp\",\n                    thread_id=None,\n                    user=None,\n                    token=None,\n                    user_env={\"API_KEY\": \"secret\"},\n                )\n\n                result = session.to_persistable()\n\n                assert result[\"env\"] == {}\n        finally:\n            user_sessions.clear()\n            user_sessions.update(original_sessions)\n\n\nclass TestHTTPSession:\n    \"\"\"Test suite for HTTPSession class.\"\"\"\n\n    def test_http_session_initialization(self):\n        \"\"\"Test HTTPSession initialization.\"\"\"\n        session = HTTPSession(\n            id=\"http_id\",\n            client_type=\"copilot\",\n            thread_id=None,\n            user=None,\n            token=None,\n            user_env=None,\n        )\n\n        assert session.id == \"http_id\"\n        assert session.client_type == \"copilot\"\n        assert isinstance(session, BaseSession)\n\n    @pytest.mark.asyncio\n    async def test_http_session_delete(self):\n        \"\"\"Test HTTPSession delete method.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = HTTPSession(\n                    id=\"http_id\",\n                    client_type=\"copilot\",\n                )\n\n                # Create files directory\n                session.files_dir.mkdir(exist_ok=True)\n                test_file = session.files_dir / \"test.txt\"\n                test_file.write_text(\"test\")\n\n                assert session.files_dir.exists()\n\n                await session.delete()\n\n                assert not session.files_dir.exists()\n\n\nclass TestWebsocketSession:\n    \"\"\"Test suite for WebsocketSession class.\"\"\"\n\n    def test_websocket_session_initialization(self):\n        \"\"\"Test WebsocketSession initialization.\"\"\"\n        emit_mock = Mock()\n        emit_call_mock = Mock()\n\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=emit_mock,\n            emit_call=emit_call_mock,\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        assert session.id == \"ws_id\"\n        assert session.socket_id == \"socket_123\"\n        assert session.emit == emit_mock\n        assert session.emit_call == emit_call_mock\n        assert session.restored is False\n        assert session.mcp_sessions == {}\n\n    def test_websocket_session_language_detection(self):\n        \"\"\"Test WebsocketSession language detection from HTTP headers.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n            environ={\"HTTP_ACCEPT_LANGUAGE\": \"fr-FR,fr;q=0.9,en;q=0.8\"},\n        )\n\n        assert session.language == \"fr-FR\"\n\n    def test_websocket_session_default_language(self):\n        \"\"\"Test WebsocketSession defaults to en-US without language header.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n            environ={},\n        )\n\n        assert session.language == \"en-US\"\n\n    def test_websocket_session_restore(self):\n        \"\"\"Test WebsocketSession restore method.\"\"\"\n        from chainlit.session import ws_sessions_sid\n\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"old_socket\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        assert ws_sessions_sid.get(\"old_socket\") == session\n\n        session.restore(\"new_socket\")\n\n        assert session.socket_id == \"new_socket\"\n        assert session.restored is True\n        assert ws_sessions_sid.get(\"old_socket\") is None\n        assert ws_sessions_sid.get(\"new_socket\") == session\n\n    @pytest.mark.asyncio\n    async def test_websocket_session_delete(self):\n        \"\"\"Test WebsocketSession delete method.\"\"\"\n        from chainlit.session import ws_sessions_id, ws_sessions_sid\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = WebsocketSession(\n                    id=\"ws_id\",\n                    socket_id=\"socket_123\",\n                    emit=Mock(),\n                    emit_call=Mock(),\n                    user_env={},\n                    client_type=\"webapp\",\n                )\n\n                # Create files directory\n                session.files_dir.mkdir(exist_ok=True)\n\n                assert ws_sessions_sid.get(\"socket_123\") == session\n                assert ws_sessions_id.get(\"ws_id\") == session\n\n                await session.delete()\n\n                assert not session.files_dir.exists()\n                assert ws_sessions_sid.get(\"socket_123\") is None\n                assert ws_sessions_id.get(\"ws_id\") is None\n\n    def test_websocket_session_get(self):\n        \"\"\"Test WebsocketSession.get class method.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        retrieved = WebsocketSession.get(\"socket_123\")\n        assert retrieved == session\n\n    def test_websocket_session_get_by_id(self):\n        \"\"\"Test WebsocketSession.get_by_id class method.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        retrieved = WebsocketSession.get_by_id(\"ws_id\")\n        assert retrieved == session\n\n    def test_websocket_session_require_success(self):\n        \"\"\"Test WebsocketSession.require with existing session.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        retrieved = WebsocketSession.require(\"socket_123\")\n        assert retrieved == session\n\n    def test_websocket_session_require_failure(self):\n        \"\"\"Test WebsocketSession.require raises error for missing session.\"\"\"\n        with pytest.raises(ValueError, match=\"Session not found\"):\n            WebsocketSession.require(\"nonexistent_socket\")\n\n    @pytest.mark.asyncio\n    async def test_websocket_session_flush_method_queue(self):\n        \"\"\"Test WebsocketSession flush_method_queue.\"\"\"\n        from collections import deque\n\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n        )\n\n        # Create a mock async method\n        mock_method = AsyncMock()\n\n        # Add items to queue\n        session.thread_queues[\"test_method\"] = deque(\n            [\n                (mock_method, session, (\"arg1\",), {\"kwarg1\": \"value1\"}),\n                (mock_method, session, (\"arg2\",), {\"kwarg2\": \"value2\"}),\n            ]\n        )\n\n        await session.flush_method_queue()\n\n        assert mock_method.call_count == 2\n        assert len(session.thread_queues[\"test_method\"]) == 0\n\n\nclass TestSessionEdgeCases:\n    \"\"\"Test suite for session edge cases.\"\"\"\n\n    def test_base_session_with_all_client_types(self):\n        \"\"\"Test BaseSession with different client types.\"\"\"\n        client_types = [\"webapp\", \"copilot\", \"teams\", \"slack\", \"discord\"]\n\n        for client_type in client_types:\n            session = BaseSession(\n                id=f\"test_{client_type}\",\n                client_type=client_type,\n                thread_id=None,\n                user=None,\n                token=None,\n                user_env=None,\n            )\n            assert session.client_type == client_type\n\n    @pytest.mark.asyncio\n    async def test_persist_file_with_mime_extension(self):\n        \"\"\"Test that persist_file adds correct file extension based on MIME type.\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = BaseSession(\n                    id=\"test_id\",\n                    client_type=\"webapp\",\n                    thread_id=None,\n                    user=None,\n                    token=None,\n                    user_env=None,\n                )\n\n                # Test with image MIME type\n                result = await session.persist_file(\n                    name=\"image.png\",\n                    mime=\"image/png\",\n                    content=b\"fake image data\",\n                )\n\n                file_id = result[\"id\"]\n                file_path = session.files[file_id][\"path\"]\n                assert file_path.suffix == \".png\"\n\n    def test_clean_metadata_with_empty_dict(self):\n        \"\"\"Test clean_metadata with empty dictionary.\"\"\"\n        result = clean_metadata({})\n        assert result == {}\n\n    def test_websocket_session_with_chat_profile(self):\n        \"\"\"Test WebsocketSession with chat profile.\"\"\"\n        session = WebsocketSession(\n            id=\"ws_id\",\n            socket_id=\"socket_123\",\n            emit=Mock(),\n            emit_call=Mock(),\n            user_env={},\n            client_type=\"webapp\",\n            chat_profile=\"gpt-4\",\n        )\n\n        assert session.chat_profile == \"gpt-4\"\n\n    @pytest.mark.asyncio\n    async def test_websocket_session_delete_with_mcp_sessions(self):\n        \"\"\"Test WebsocketSession delete with MCP sessions.\"\"\"\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with patch(\"chainlit.config.FILES_DIRECTORY\", Path(tmpdir)):\n                session = WebsocketSession(\n                    id=\"ws_id\",\n                    socket_id=\"socket_123\",\n                    emit=Mock(),\n                    emit_call=Mock(),\n                    user_env={},\n                    client_type=\"webapp\",\n                )\n\n                # Mock MCP session with exit stack\n                mock_exit_stack = AsyncMock()\n                session.mcp_sessions[\"mcp1\"] = (Mock(), mock_exit_stack)\n\n                await session.delete()\n\n                mock_exit_stack.aclose.assert_called_once()\n"
  },
  {
    "path": "backend/tests/test_sidebar.py",
    "content": "import pytest\n\nfrom chainlit.element import File, Image, Text\nfrom chainlit.sidebar import ElementSidebar\n\n\n@pytest.mark.asyncio\nclass TestElementSidebar:\n    \"\"\"Test suite for ElementSidebar class.\"\"\"\n\n    async def test_set_title(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_title() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            await ElementSidebar.set_title(\"My Sidebar Title\")\n\n            ctx.emitter.emit.assert_called_once_with(\n                \"set_sidebar_title\", \"My Sidebar Title\"\n            )\n\n    async def test_set_title_with_empty_string(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_title() with empty string.\"\"\"\n        async with mock_chainlit_context as ctx:\n            await ElementSidebar.set_title(\"\")\n\n            ctx.emitter.emit.assert_called_once_with(\"set_sidebar_title\", \"\")\n\n    async def test_set_title_with_special_characters(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_title() with special characters.\"\"\"\n        async with mock_chainlit_context as ctx:\n            title = \"Title with 特殊字符 & symbols! 🎉\"\n            await ElementSidebar.set_title(title)\n\n            ctx.emitter.emit.assert_called_once_with(\"set_sidebar_title\", title)\n\n    async def test_set_elements_with_single_element(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with a single element.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(name=\"test.txt\", url=\"https://example.com/test.txt\")\n\n            await ElementSidebar.set_elements([element])\n\n            # Verify element.send() was called\n            ctx.emitter.send_element.assert_called_once()\n\n            # Verify emit was called with correct structure\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"set_sidebar_elements\"\n            assert \"elements\" in call_args[0][1]\n            assert \"key\" in call_args[0][1]\n            assert len(call_args[0][1][\"elements\"]) == 1\n            assert call_args[0][1][\"key\"] is None\n\n    async def test_set_elements_with_multiple_elements(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with multiple elements.\"\"\"\n        async with mock_chainlit_context as ctx:\n            elements = [\n                File(name=\"file1.txt\", url=\"https://example.com/file1.txt\"),\n                Image(name=\"image1.png\", url=\"https://example.com/image1.png\"),\n                Text(name=\"text1\", content=\"Some text content\"),\n            ]\n\n            await ElementSidebar.set_elements(elements)\n\n            # Verify all elements were sent (3 send_element calls)\n            assert ctx.emitter.send_element.call_count == 3\n\n            # Verify emit was called with all elements\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"set_sidebar_elements\"\n            assert len(call_args[0][1][\"elements\"]) == 3\n\n    async def test_set_elements_with_empty_list(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with empty list (closes sidebar).\"\"\"\n        async with mock_chainlit_context as ctx:\n            await ElementSidebar.set_elements([])\n\n            # No elements to send\n            ctx.emitter.send_element.assert_not_called()\n\n            # Emit should still be called with empty elements\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][0] == \"set_sidebar_elements\"\n            assert call_args[0][1][\"elements\"] == []\n            assert call_args[0][1][\"key\"] is None\n\n    async def test_set_elements_with_key(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with a key.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(name=\"test.txt\", url=\"https://example.com/test.txt\")\n            key = \"my_sidebar_key\"\n\n            await ElementSidebar.set_elements([element], key=key)\n\n            # Verify emit was called with the key\n            ctx.emitter.emit.assert_called_once()\n            call_args = ctx.emitter.emit.call_args\n            assert call_args[0][1][\"key\"] == key\n\n    async def test_set_elements_with_for_id(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with elements that have for_id.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(\n                name=\"test.txt\",\n                url=\"https://example.com/test.txt\",\n                for_id=\"message_123\",\n            )\n\n            await ElementSidebar.set_elements([element])\n\n            # Element should be sent with its for_id\n            ctx.emitter.send_element.assert_called_once()\n\n            # Verify emit was called\n            ctx.emitter.emit.assert_called_once()\n\n    async def test_set_elements_without_for_id(self, mock_chainlit_context):\n        \"\"\"Test ElementSidebar.set_elements() with elements without for_id.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element = File(name=\"test.txt\", url=\"https://example.com/test.txt\")\n\n            await ElementSidebar.set_elements([element])\n\n            # Element should be sent with empty string for_id\n            ctx.emitter.send_element.assert_called_once()\n\n            # Verify emit was called\n            ctx.emitter.emit.assert_called_once()\n\n    async def test_set_elements_persist_false(self, mock_chainlit_context):\n        \"\"\"Test that set_elements() sends elements with persist=False.\"\"\"\n        async with mock_chainlit_context as ctx:\n            # Mock persist_file to provide chainlit_key\n            ctx.session.persist_file.return_value = {\"id\": \"test_key\"}\n\n            element = File(name=\"test.txt\", content=b\"test content\")\n\n            await ElementSidebar.set_elements([element])\n\n            # persist_file is still called to get chainlit_key, even with persist=False\n            # The persist=False affects data layer persistence, not file upload\n            ctx.session.persist_file.assert_called_once()\n\n            # Verify element was sent\n            ctx.emitter.send_element.assert_called_once()\n\n    async def test_set_elements_serialization(self, mock_chainlit_context):\n        \"\"\"Test that elements are properly serialized in set_elements().\"\"\"\n        async with mock_chainlit_context as ctx:\n            file_elem = File(name=\"file.txt\", url=\"https://example.com/file.txt\")\n            image_elem = Image(\n                name=\"image.png\", url=\"https://example.com/image.png\", size=\"large\"\n            )\n\n            await ElementSidebar.set_elements([file_elem, image_elem])\n\n            # Verify emit was called with serialized elements\n            call_args = ctx.emitter.emit.call_args\n            elements_data = call_args[0][1][\"elements\"]\n\n            assert len(elements_data) == 2\n            assert elements_data[0][\"name\"] == \"file.txt\"\n            assert elements_data[0][\"type\"] == \"file\"\n            assert elements_data[1][\"name\"] == \"image.png\"\n            assert elements_data[1][\"type\"] == \"image\"\n            assert elements_data[1][\"size\"] == \"large\"\n\n\n@pytest.mark.asyncio\nclass TestElementSidebarEdgeCases:\n    \"\"\"Test suite for ElementSidebar edge cases.\"\"\"\n\n    async def test_set_title_multiple_times(self, mock_chainlit_context):\n        \"\"\"Test calling set_title() multiple times.\"\"\"\n        async with mock_chainlit_context as ctx:\n            await ElementSidebar.set_title(\"First Title\")\n            await ElementSidebar.set_title(\"Second Title\")\n            await ElementSidebar.set_title(\"Third Title\")\n\n            assert ctx.emitter.emit.call_count == 3\n\n            # Verify last call had the third title\n            last_call = ctx.emitter.emit.call_args\n            assert last_call[0][1] == \"Third Title\"\n\n    async def test_set_elements_multiple_times(self, mock_chainlit_context):\n        \"\"\"Test calling set_elements() multiple times.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element1 = File(name=\"file1.txt\", url=\"https://example.com/file1.txt\")\n            element2 = File(name=\"file2.txt\", url=\"https://example.com/file2.txt\")\n\n            await ElementSidebar.set_elements([element1])\n            await ElementSidebar.set_elements([element2])\n\n            # Should have sent both elements\n            assert ctx.emitter.send_element.call_count == 2\n\n            # Should have emitted twice\n            assert ctx.emitter.emit.call_count == 2\n\n    async def test_set_elements_with_same_key_twice(self, mock_chainlit_context):\n        \"\"\"Test calling set_elements() with the same key twice.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element1 = File(name=\"file1.txt\", url=\"https://example.com/file1.txt\")\n            element2 = File(name=\"file2.txt\", url=\"https://example.com/file2.txt\")\n\n            await ElementSidebar.set_elements([element1], key=\"same_key\")\n            await ElementSidebar.set_elements([element2], key=\"same_key\")\n\n            # Both should be sent (server doesn't prevent this)\n            assert ctx.emitter.send_element.call_count == 2\n            assert ctx.emitter.emit.call_count == 2\n\n    async def test_set_elements_with_different_keys(self, mock_chainlit_context):\n        \"\"\"Test calling set_elements() with different keys.\"\"\"\n        async with mock_chainlit_context as ctx:\n            element1 = File(name=\"file1.txt\", url=\"https://example.com/file1.txt\")\n            element2 = File(name=\"file2.txt\", url=\"https://example.com/file2.txt\")\n\n            await ElementSidebar.set_elements([element1], key=\"key1\")\n            await ElementSidebar.set_elements([element2], key=\"key2\")\n\n            assert ctx.emitter.emit.call_count == 2\n\n            # Verify different keys were used\n            calls = ctx.emitter.emit.call_args_list\n            assert calls[0][0][1][\"key\"] == \"key1\"\n            assert calls[1][0][1][\"key\"] == \"key2\"\n\n    async def test_set_elements_with_large_number_of_elements(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test set_elements() with many elements.\"\"\"\n        async with mock_chainlit_context as ctx:\n            # Create 50 elements\n            elements = [\n                File(name=f\"file{i}.txt\", url=f\"https://example.com/file{i}.txt\")\n                for i in range(50)\n            ]\n\n            await ElementSidebar.set_elements(elements)\n\n            # All 50 elements should be sent\n            assert ctx.emitter.send_element.call_count == 50\n\n            # Verify emit was called with all 50 elements\n            call_args = ctx.emitter.emit.call_args\n            assert len(call_args[0][1][\"elements\"]) == 50\n\n    async def test_set_title_and_set_elements_together(self, mock_chainlit_context):\n        \"\"\"Test using set_title() and set_elements() together.\"\"\"\n        async with mock_chainlit_context as ctx:\n            await ElementSidebar.set_title(\"My Documents\")\n\n            elements = [\n                File(name=\"doc1.pdf\", url=\"https://example.com/doc1.pdf\"),\n                File(name=\"doc2.pdf\", url=\"https://example.com/doc2.pdf\"),\n            ]\n            await ElementSidebar.set_elements(elements)\n\n            # Verify both methods were called\n            assert ctx.emitter.emit.call_count == 2\n\n            # Verify the calls were correct\n            calls = ctx.emitter.emit.call_args_list\n            assert calls[0][0][0] == \"set_sidebar_title\"\n            assert calls[0][0][1] == \"My Documents\"\n            assert calls[1][0][0] == \"set_sidebar_elements\"\n\n    async def test_set_elements_with_mixed_element_types(self, mock_chainlit_context):\n        \"\"\"Test set_elements() with various element types.\"\"\"\n        async with mock_chainlit_context as ctx:\n            elements = [\n                File(name=\"document.pdf\", url=\"https://example.com/doc.pdf\"),\n                Image(\n                    name=\"photo.jpg\", url=\"https://example.com/photo.jpg\", size=\"medium\"\n                ),\n                Text(name=\"notes\", content=\"Some important notes\"),\n            ]\n\n            await ElementSidebar.set_elements(elements)\n\n            # Verify all different types were sent\n            assert ctx.emitter.send_element.call_count == 3\n\n            # Verify serialization includes type information\n            call_args = ctx.emitter.emit.call_args\n            elements_data = call_args[0][1][\"elements\"]\n\n            assert elements_data[0][\"type\"] == \"file\"\n            assert elements_data[1][\"type\"] == \"image\"\n            assert elements_data[2][\"type\"] == \"text\"\n\n    async def test_set_title_with_long_string(self, mock_chainlit_context):\n        \"\"\"Test set_title() with a very long title.\"\"\"\n        async with mock_chainlit_context as ctx:\n            long_title = \"A\" * 1000  # 1000 character title\n\n            await ElementSidebar.set_title(long_title)\n\n            ctx.emitter.emit.assert_called_once_with(\"set_sidebar_title\", long_title)\n"
  },
  {
    "path": "backend/tests/test_slack_socket_mode.py",
    "content": "# tests/test_slack_socket_mode.py\nimport importlib\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_start_socket_mode_starts_handler(monkeypatch):\n    \"\"\"\n    The function should:\n      • build an AsyncSocketModeHandler with the global slack_app\n      • use the token found in SLACK_WEBSOCKET_TOKEN\n      • await the handler.start_async() coroutine exactly once\n    \"\"\"\n    token = \"xapp-fake-token\"\n    # minimal env required for the Slack module to initialise\n    monkeypatch.setenv(\"SLACK_BOT_TOKEN\", \"xoxb-fake-bot\")\n    monkeypatch.setenv(\"SLACK_WEBSOCKET_TOKEN\", token)\n\n    # Import the module first to avoid lazy import registry issues\n    slack_app_mod = importlib.import_module(\"chainlit.slack.app\")\n\n    # Patch the object directly instead of using string path\n    with patch.object(\n        slack_app_mod, \"AsyncSocketModeHandler\", autospec=True\n    ) as handler_cls:\n        handler_instance = AsyncMock()\n        handler_cls.return_value = handler_instance\n\n        # Run: should build handler + await start_async\n        await slack_app_mod.start_socket_mode()\n\n        handler_cls.assert_called_once_with(slack_app_mod.slack_app, token)\n        handler_instance.start_async.assert_awaited_once()\n\n\ndef test_slack_http_route_registered(monkeypatch):\n    \"\"\"\n    When only the classic HTTP tokens are set (no websocket token),\n    the FastAPI app should expose POST /slack/events.\n    \"\"\"\n    # HTTP-only environment\n    monkeypatch.setenv(\"SLACK_BOT_TOKEN\", \"xoxb-fake-bot\")\n    monkeypatch.setenv(\"SLACK_SIGNING_SECRET\", \"shhh-fake-secret\")\n    monkeypatch.delenv(\"SLACK_WEBSOCKET_TOKEN\", raising=False)\n\n    # Re-import server with the fresh env so the route table is built correctly\n    server = importlib.reload(importlib.import_module(\"chainlit.server\"))\n\n    assert any(\n        route.path == \"/slack/events\" and \"POST\" in route.methods\n        for route in server.router.routes\n    ), \"Slack HTTP handler route was not registered\"\n"
  },
  {
    "path": "backend/tests/test_socket.py",
    "content": "import json\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom chainlit.session import WebsocketSession\nfrom chainlit.socket import (\n    _authenticate_connection,\n    _get_token,\n    _get_token_from_cookie,\n    clean_session,\n    load_user_env,\n    persist_user_session,\n    restore_existing_session,\n    resume_thread,\n)\n\n\nclass TestGetTokenFromCookie:\n    \"\"\"Test suite for _get_token_from_cookie function.\"\"\"\n\n    def test_get_token_from_cookie_with_valid_cookie(self):\n        \"\"\"Test extracting token from valid cookie header.\"\"\"\n        with patch(\"chainlit.socket.get_token_from_cookies\") as mock_get_token:\n            mock_get_token.return_value = \"test_token\"\n            environ = {\"HTTP_COOKIE\": \"session=abc123; token=test_token\"}\n\n            result = _get_token_from_cookie(environ)\n\n            assert result == \"test_token\"\n            mock_get_token.assert_called_once()\n\n    def test_get_token_from_cookie_without_cookie(self):\n        \"\"\"Test when no cookie header is present.\"\"\"\n        environ = {}\n        result = _get_token_from_cookie(environ)\n        assert result is None\n\n    def test_get_token_from_cookie_with_empty_cookie(self):\n        \"\"\"Test with empty cookie header.\"\"\"\n        with patch(\"chainlit.socket.get_token_from_cookies\") as mock_get_token:\n            mock_get_token.return_value = None\n            environ = {\"HTTP_COOKIE\": \"\"}\n\n            result = _get_token_from_cookie(environ)\n\n            assert result is None\n\n\nclass TestGetToken:\n    \"\"\"Test suite for _get_token function.\"\"\"\n\n    def test_get_token_calls_get_token_from_cookie(self):\n        \"\"\"Test that _get_token delegates to _get_token_from_cookie.\"\"\"\n        with patch(\"chainlit.socket._get_token_from_cookie\") as mock_get_cookie:\n            mock_get_cookie.return_value = \"token_value\"\n            environ = {\"HTTP_COOKIE\": \"token=token_value\"}\n\n            result = _get_token(environ)\n\n            assert result == \"token_value\"\n            mock_get_cookie.assert_called_once_with(environ)\n\n\nclass TestAuthenticateConnection:\n    \"\"\"Test suite for _authenticate_connection function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_authenticate_connection_with_valid_token(self):\n        \"\"\"Test authentication with valid token.\"\"\"\n        mock_user = Mock()\n        mock_user.identifier = \"user123\"\n\n        with patch(\"chainlit.socket._get_token\") as mock_get_token:\n            with patch(\"chainlit.socket.get_current_user\") as mock_get_user:\n                mock_get_token.return_value = \"valid_token\"\n                mock_get_user.return_value = mock_user\n\n                environ = {\"HTTP_COOKIE\": \"token=valid_token\"}\n                user, token = await _authenticate_connection(environ)\n\n                assert user == mock_user\n                assert token == \"valid_token\"\n                mock_get_user.assert_called_once_with(token=\"valid_token\")\n\n    @pytest.mark.asyncio\n    async def test_authenticate_connection_without_token(self):\n        \"\"\"Test authentication without token.\"\"\"\n        with patch(\"chainlit.socket._get_token\") as mock_get_token:\n            mock_get_token.return_value = None\n\n            environ = {}\n            user, token = await _authenticate_connection(environ)\n\n            assert user is None\n            assert token is None\n\n    @pytest.mark.asyncio\n    async def test_authenticate_connection_with_invalid_token(self):\n        \"\"\"Test authentication with invalid token.\"\"\"\n        with patch(\"chainlit.socket._get_token\") as mock_get_token:\n            with patch(\"chainlit.socket.get_current_user\") as mock_get_user:\n                mock_get_token.return_value = \"invalid_token\"\n                mock_get_user.return_value = None\n\n                environ = {\"HTTP_COOKIE\": \"token=invalid_token\"}\n                user, token = await _authenticate_connection(environ)\n\n                assert user is None\n                assert token is None\n\n\nclass TestRestoreExistingSession:\n    \"\"\"Test suite for restore_existing_session function.\"\"\"\n\n    def test_restore_existing_session_success(self):\n        \"\"\"Test restoring an existing session.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        emit_fn = Mock()\n        emit_call_fn = Mock()\n        environ = {\"HTTP_COOKIE\": \"token=token\"}\n\n        with patch.object(WebsocketSession, \"get_by_id\") as mock_get:\n            mock_get.return_value = mock_session\n\n            result = restore_existing_session(\n                \"new_sid\", \"session_123\", emit_fn, emit_call_fn, environ\n            )\n\n            assert result is True\n            mock_session.restore.assert_called_once_with(new_socket_id=\"new_sid\")\n            assert mock_session.emit == emit_fn\n            assert mock_session.emit_call == emit_call_fn\n            assert mock_session.environ == environ\n\n    def test_restore_existing_session_not_found(self):\n        \"\"\"Test when session is not found.\"\"\"\n        with patch.object(WebsocketSession, \"get_by_id\") as mock_get:\n            mock_get.return_value = None\n\n            result = restore_existing_session(\n                \"new_sid\", \"session_123\", Mock(), Mock(), {\"HTTP_COOKIE\": \"token=token\"}\n            )\n\n            assert result is False\n\n\nclass TestPersistUserSession:\n    \"\"\"Test suite for persist_user_session function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_persist_user_session_with_data_layer(self):\n        \"\"\"Test persisting user session with data layer.\"\"\"\n        mock_data_layer = AsyncMock()\n\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = mock_data_layer\n\n            metadata = {\"key\": \"value\"}\n            await persist_user_session(\"thread_123\", metadata)\n\n            mock_data_layer.update_thread.assert_called_once_with(\n                thread_id=\"thread_123\", metadata=metadata\n            )\n\n    @pytest.mark.asyncio\n    async def test_persist_user_session_without_data_layer(self):\n        \"\"\"Test persisting when no data layer is available.\"\"\"\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = None\n\n            # Should not raise an error\n            await persist_user_session(\"thread_123\", {\"key\": \"value\"})\n\n\nclass TestResumeThread:\n    \"\"\"Test suite for resume_thread function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_without_data_layer(self):\n        \"\"\"Test resume thread when no data layer exists.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock()\n        mock_session.thread_id_to_resume = \"thread_123\"\n\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = None\n\n            result = await resume_thread(mock_session)\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_without_user(self):\n        \"\"\"Test resume thread when session has no user.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = None\n        mock_session.thread_id_to_resume = \"thread_123\"\n\n        result = await resume_thread(mock_session)\n\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_without_thread_id(self):\n        \"\"\"Test resume thread when no thread_id_to_resume.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock()\n        mock_session.thread_id_to_resume = None\n\n        result = await resume_thread(mock_session)\n\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_thread_not_found(self):\n        \"\"\"Test resume thread when thread doesn't exist.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock(identifier=\"user123\")\n        mock_session.thread_id_to_resume = \"thread_123\"\n        mock_session.id = \"session_123\"\n\n        mock_data_layer = AsyncMock()\n        mock_data_layer.get_thread.return_value = None\n\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = mock_data_layer\n\n            result = await resume_thread(mock_session)\n\n            assert result is None\n            mock_data_layer.get_thread.assert_called_once_with(thread_id=\"thread_123\")\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_user_not_author(self):\n        \"\"\"Test resume thread when user is not the thread author.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock(identifier=\"user123\")\n        mock_session.thread_id_to_resume = \"thread_123\"\n        mock_session.id = \"session_123\"\n\n        thread = {\"userIdentifier\": \"different_user\", \"metadata\": {}}\n        mock_data_layer = AsyncMock()\n        mock_data_layer.get_thread.return_value = thread\n\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = mock_data_layer\n\n            result = await resume_thread(mock_session)\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_success(self):\n        \"\"\"Test successful thread resumption.\"\"\"\n        from chainlit.user_session import user_sessions\n\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock(identifier=\"user123\")\n        mock_session.thread_id_to_resume = \"thread_123\"\n        mock_session.id = \"session_123\"\n\n        metadata = {\n            \"chat_profile\": \"gpt-4\",\n            \"chat_settings\": {\"temperature\": 0.7},\n        }\n        thread = {\"userIdentifier\": \"user123\", \"metadata\": metadata}\n\n        mock_data_layer = AsyncMock()\n        mock_data_layer.get_thread.return_value = thread\n\n        original_sessions = user_sessions.copy()\n        try:\n            with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n                mock_get_dl.return_value = mock_data_layer\n\n                result = await resume_thread(mock_session)\n\n                assert result == thread\n                assert mock_session.chat_profile == \"gpt-4\"\n                assert mock_session.chat_settings == {\"temperature\": 0.7}\n                assert user_sessions.get(\"session_123\") == metadata\n        finally:\n            user_sessions.clear()\n            user_sessions.update(original_sessions)\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_with_string_metadata(self):\n        \"\"\"Test thread resumption with JSON string metadata.\"\"\"\n        from chainlit.user_session import user_sessions\n\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock(identifier=\"user123\")\n        mock_session.thread_id_to_resume = \"thread_123\"\n        mock_session.id = \"session_123\"\n\n        metadata_dict = {\"chat_profile\": \"gpt-4\"}\n        thread = {\n            \"userIdentifier\": \"user123\",\n            \"metadata\": json.dumps(metadata_dict),\n        }\n\n        mock_data_layer = AsyncMock()\n        mock_data_layer.get_thread.return_value = thread\n\n        original_sessions = user_sessions.copy()\n        try:\n            with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n                mock_get_dl.return_value = mock_data_layer\n\n                result = await resume_thread(mock_session)\n\n                assert result == thread\n                assert mock_session.chat_profile == \"gpt-4\"\n        finally:\n            user_sessions.clear()\n            user_sessions.update(original_sessions)\n\n\nclass TestLoadUserEnv:\n    \"\"\"Test suite for load_user_env function.\"\"\"\n\n    def test_load_user_env_with_valid_json(self):\n        \"\"\"Test loading valid user environment JSON.\"\"\"\n        user_env = '{\"API_KEY\": \"secret\", \"ENV_VAR\": \"value\"}'\n\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = []\n\n            result = load_user_env(user_env)\n\n            assert result == {\"API_KEY\": \"secret\", \"ENV_VAR\": \"value\"}\n\n    def test_load_user_env_with_required_keys(self):\n        \"\"\"Test loading user env with required keys.\"\"\"\n        user_env = '{\"API_KEY\": \"secret\", \"OTHER_KEY\": \"value\"}'\n\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = [\"API_KEY\", \"OTHER_KEY\"]\n\n            result = load_user_env(user_env)\n\n            assert result == {\"API_KEY\": \"secret\", \"OTHER_KEY\": \"value\"}\n\n    def test_load_user_env_missing_required_key(self):\n        \"\"\"Test error when required key is missing.\"\"\"\n        user_env = '{\"API_KEY\": \"secret\"}'\n\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = [\"API_KEY\", \"MISSING_KEY\"]\n\n            with pytest.raises(\n                ConnectionRefusedError, match=\"Missing user environment variable\"\n            ):\n                load_user_env(user_env)\n\n    def test_load_user_env_none_with_required_keys(self):\n        \"\"\"Test error when user_env is None but keys are required.\"\"\"\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = [\"API_KEY\"]\n\n            # The function has a bug - it raises UnboundLocalError instead of ConnectionRefusedError\n            # Python 3.10: \"referenced before assignment\"\n            # Python 3.11+: \"cannot access local variable\"\n            with pytest.raises(UnboundLocalError, match=\"user_env_dict\"):\n                load_user_env(None)\n\n    def test_load_user_env_none_without_required_keys(self):\n        \"\"\"Test when user_env is None and no keys are required.\"\"\"\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = []\n\n            # The function has a bug - it raises NameError when user_env is None\n            # even when no required keys are configured\n            with pytest.raises(NameError, match=\"user_env_dict\"):\n                load_user_env(None)\n\n\nclass TestCleanSession:\n    \"\"\"Test suite for clean_session function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_clean_session_with_existing_session(self):\n        \"\"\"Test marking session for cleanup.\"\"\"\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.to_clear = False\n\n        with patch.object(WebsocketSession, \"get\") as mock_get:\n            mock_get.return_value = mock_session\n\n            await clean_session(\"socket_123\")\n\n            assert mock_session.to_clear is True\n            mock_get.assert_called_once_with(\"socket_123\")\n\n    @pytest.mark.asyncio\n    async def test_clean_session_without_session(self):\n        \"\"\"Test clean_session when session doesn't exist.\"\"\"\n        with patch.object(WebsocketSession, \"get\") as mock_get:\n            mock_get.return_value = None\n\n            # Should not raise an error\n            await clean_session(\"socket_123\")\n\n\nclass TestSocketEdgeCases:\n    \"\"\"Test suite for socket edge cases.\"\"\"\n\n    def test_restore_existing_session_with_none_session_id(self):\n        \"\"\"Test restore with None session_id.\"\"\"\n        with patch.object(WebsocketSession, \"get_by_id\") as mock_get:\n            mock_get.return_value = None\n\n            result = restore_existing_session(None, None, Mock(), Mock(), None)\n\n            assert result is False\n\n    @pytest.mark.asyncio\n    async def test_persist_user_session_with_empty_metadata(self):\n        \"\"\"Test persisting empty metadata.\"\"\"\n        mock_data_layer = AsyncMock()\n\n        with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n            mock_get_dl.return_value = mock_data_layer\n\n            await persist_user_session(\"thread_123\", {})\n\n            mock_data_layer.update_thread.assert_called_once_with(\n                thread_id=\"thread_123\", metadata={}\n            )\n\n    def test_load_user_env_with_empty_json(self):\n        \"\"\"Test loading empty user environment.\"\"\"\n        user_env = \"{}\"\n\n        with patch(\"chainlit.socket.config\") as mock_config:\n            mock_config.project.user_env = []\n\n            result = load_user_env(user_env)\n\n            assert result == {}\n\n    @pytest.mark.asyncio\n    async def test_resume_thread_with_empty_metadata(self):\n        \"\"\"Test resuming thread with empty metadata.\"\"\"\n        from chainlit.user_session import user_sessions\n\n        mock_session = Mock(spec=WebsocketSession)\n        mock_session.user = Mock(identifier=\"user123\")\n        mock_session.thread_id_to_resume = \"thread_123\"\n        mock_session.id = \"session_123\"\n\n        thread = {\"userIdentifier\": \"user123\", \"metadata\": {}}\n\n        mock_data_layer = AsyncMock()\n        mock_data_layer.get_thread.return_value = thread\n\n        original_sessions = user_sessions.copy()\n        try:\n            with patch(\"chainlit.socket.get_data_layer\") as mock_get_dl:\n                mock_get_dl.return_value = mock_data_layer\n\n                result = await resume_thread(mock_session)\n\n                assert result == thread\n                assert user_sessions.get(\"session_123\") == {}\n        finally:\n            user_sessions.clear()\n            user_sessions.update(original_sessions)\n\n    @pytest.mark.asyncio\n    async def test_authenticate_connection_with_exception(self):\n        \"\"\"Test authentication when get_current_user raises exception.\"\"\"\n        with patch(\"chainlit.socket._get_token\") as mock_get_token:\n            with patch(\"chainlit.socket.get_current_user\") as mock_get_user:\n                mock_get_token.return_value = \"token\"\n                mock_get_user.side_effect = Exception(\"Auth error\")\n\n                environ = {\"HTTP_COOKIE\": \"token=token\"}\n\n                # Should propagate the exception\n                with pytest.raises(Exception, match=\"Auth error\"):\n                    await _authenticate_connection(environ)\n"
  },
  {
    "path": "backend/tests/test_step.py",
    "content": "import sys\nimport uuid\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom chainlit.context import local_steps\nfrom chainlit.element import Element\nfrom chainlit.step import (\n    Step,\n    check_add_step_in_cot,\n    flatten_args_kwargs,\n    step,\n    stub_step,\n)\n\n\n@pytest.mark.asyncio\nclass TestStepClass:\n    \"\"\"Test suite for the Step class.\"\"\"\n\n    async def test_step_initialization_with_defaults(self, mock_chainlit_context):\n        \"\"\"Test Step initialization with default values.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test_step\")\n\n            assert test_step.name == \"test_step\"\n            assert test_step.type == \"undefined\"\n            assert isinstance(test_step.id, str)\n            uuid.UUID(test_step.id)  # Verify valid UUID\n            assert test_step.parent_id is None\n            assert test_step.metadata == {}\n            assert test_step.tags is None\n            assert test_step.is_error is False\n            assert test_step.show_input == \"json\"\n            assert test_step.language is None\n            assert test_step.default_open is False\n            assert test_step.elements == []\n            assert test_step.streaming is False\n            assert test_step.persisted is False\n            assert test_step.fail_on_persist_error is False\n            assert test_step.input == \"\"\n            assert test_step.output == \"\"\n            assert test_step.created_at is not None\n            assert test_step.start is None\n            assert test_step.end is None\n\n    async def test_step_initialization_with_all_fields(self, mock_chainlit_context):\n        \"\"\"Test Step initialization with all fields provided.\"\"\"\n        async with mock_chainlit_context:\n            test_id = str(uuid.uuid4())\n            parent_id = str(uuid.uuid4())\n            metadata = {\"key\": \"value\"}\n            tags = [\"tag1\", \"tag2\"]\n\n            test_step = Step(\n                name=\"custom_step\",\n                type=\"tool\",\n                id=test_id,\n                parent_id=parent_id,\n                metadata=metadata,\n                tags=tags,\n                language=\"python\",\n                default_open=True,\n                show_input=False,\n            )\n\n            assert test_step.name == \"custom_step\"\n            assert test_step.type == \"tool\"\n            assert test_step.id == test_id\n            assert test_step.parent_id == parent_id\n            assert test_step.metadata == metadata\n            assert test_step.tags == tags\n            assert test_step.language == \"python\"\n            assert test_step.default_open is True\n            assert test_step.show_input is False\n\n    async def test_step_input_setter_with_string(self, mock_chainlit_context):\n        \"\"\"Test Step input setter with string content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.input = \"This is input text\"\n\n            assert test_step.input == \"This is input text\"\n\n    async def test_step_input_setter_with_dict(self, mock_chainlit_context):\n        \"\"\"Test Step input setter with dictionary content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            input_dict = {\"param1\": \"value1\", \"param2\": 42}\n            test_step.input = input_dict\n\n            # Should be JSON formatted\n            assert \"param1\" in test_step.input\n            assert \"value1\" in test_step.input\n            assert isinstance(test_step.input, str)\n\n    async def test_step_output_setter_with_string(self, mock_chainlit_context):\n        \"\"\"Test Step output setter with string content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.output = \"This is output text\"\n\n            assert test_step.output == \"This is output text\"\n\n    async def test_step_output_setter_with_dict(self, mock_chainlit_context):\n        \"\"\"Test Step output setter with dictionary content and language detection.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            output_dict = {\"result\": \"success\", \"data\": [1, 2, 3]}\n            test_step.output = output_dict\n\n            # Should be JSON formatted and language set to json\n            assert \"result\" in test_step.output\n            assert \"success\" in test_step.output\n            assert test_step.language == \"json\"\n\n    async def test_step_clean_content_with_bytes(self, mock_chainlit_context):\n        \"\"\"Test that bytes in content are stripped.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            content_with_bytes = {\n                \"text\": \"hello\",\n                \"binary\": b\"binary_data\",\n                \"nested\": {\"data\": b\"more_binary\"},\n            }\n            test_step.output = content_with_bytes\n\n            assert \"STRIPPED_BINARY_DATA\" in test_step.output\n            assert b\"binary_data\" not in test_step.output.encode()\n\n    async def test_step_to_dict(self, mock_chainlit_context):\n        \"\"\"Test Step serialization to dictionary.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(\n                name=\"test_step\",\n                type=\"tool\",\n                metadata={\"key\": \"value\"},\n                tags=[\"tag1\"],\n            )\n            test_step.input = \"test input\"\n            test_step.output = \"test output\"\n\n            step_dict = test_step.to_dict()\n\n            assert step_dict[\"name\"] == \"test_step\"\n            assert step_dict[\"type\"] == \"tool\"\n            assert step_dict[\"id\"] == test_step.id\n            assert step_dict[\"threadId\"] == ctx.session.thread_id\n            assert step_dict[\"parentId\"] is None\n            assert step_dict[\"streaming\"] is False\n            assert step_dict[\"metadata\"] == {\"key\": \"value\"}\n            assert step_dict[\"tags\"] == [\"tag1\"]\n            assert step_dict[\"input\"] == \"test input\"\n            assert step_dict[\"output\"] == \"test output\"\n            assert step_dict[\"isError\"] is False\n            assert step_dict[\"createdAt\"] is not None\n            assert step_dict[\"start\"] is None\n            assert step_dict[\"end\"] is None\n\n    async def test_step_send(self, mock_chainlit_context):\n        \"\"\"Test Step.send() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test_step\")\n\n            result = await test_step.send()\n\n            assert result == test_step\n            assert test_step.persisted is False  # No data layer configured\n            ctx.emitter.send_step.assert_called_once()\n\n    async def test_step_send_with_elements(self, mock_chainlit_context):\n        \"\"\"Test Step.send() with elements.\"\"\"\n        async with mock_chainlit_context:\n            mock_element = Mock(spec=Element)\n            mock_element.send = AsyncMock()\n\n            test_step = Step(name=\"test_step\", elements=[mock_element])\n\n            await test_step.send()\n\n            mock_element.send.assert_called_once_with(for_id=test_step.id)\n\n    async def test_step_send_already_persisted(self, mock_chainlit_context):\n        \"\"\"Test that send() returns early if already persisted.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test_step\")\n            test_step.persisted = True\n\n            result = await test_step.send()\n\n            assert result == test_step\n            ctx.emitter.send_step.assert_not_called()\n\n    async def test_step_update(self, mock_chainlit_context):\n        \"\"\"Test Step.update() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test_step\")\n            test_step.streaming = True\n\n            result = await test_step.update()\n\n            assert result is True\n            assert test_step.streaming is False\n            ctx.emitter.update_step.assert_called_once()\n\n    async def test_step_remove(self, mock_chainlit_context):\n        \"\"\"Test Step.remove() method.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test_step\")\n\n            result = await test_step.remove()\n\n            assert result is True\n            ctx.emitter.delete_step.assert_called_once()\n\n    async def test_step_stream_token_output(self, mock_chainlit_context):\n        \"\"\"Test streaming tokens to output.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test_step\")\n\n            await test_step.stream_token(\"Hello\")\n            await test_step.stream_token(\" \")\n            await test_step.stream_token(\"World\")\n\n            assert test_step.output == \"Hello World\"\n            assert test_step.streaming is True\n\n    async def test_step_stream_token_input(self, mock_chainlit_context):\n        \"\"\"Test streaming tokens to input.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test_step\")\n\n            await test_step.stream_token(\"Input\", is_input=True)\n            await test_step.stream_token(\" text\", is_input=True)\n\n            assert test_step.input == \"Input text\"\n\n    async def test_step_stream_token_sequence(self, mock_chainlit_context):\n        \"\"\"Test streaming tokens with is_sequence flag.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test_step\")\n\n            await test_step.stream_token(\"First\", is_sequence=True)\n            await test_step.stream_token(\"Second\", is_sequence=True)\n\n            # With is_sequence, it replaces instead of appending\n            assert test_step.output == \"Second\"\n\n    async def test_step_stream_token_empty(self, mock_chainlit_context):\n        \"\"\"Test that empty tokens are ignored.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test_step\")\n\n            await test_step.stream_token(\"\")\n\n            assert test_step.output == \"\"\n            ctx.emitter.stream_start.assert_not_called()\n\n    async def test_step_context_manager_async(self, mock_chainlit_context):\n        \"\"\"Test Step as async context manager.\"\"\"\n        async with mock_chainlit_context as ctx:\n            async with Step(name=\"context_step\") as test_step:\n                assert test_step.start is not None\n                assert test_step.end is None\n\n            # After exiting context\n            assert test_step.end is not None\n            assert ctx.emitter.send_step.call_count == 1\n            assert ctx.emitter.update_step.call_count == 1\n\n    async def test_step_context_manager_with_exception(self, mock_chainlit_context):\n        \"\"\"Test Step context manager handles exceptions.\"\"\"\n        async with mock_chainlit_context:\n            try:\n                async with Step(name=\"error_step\") as test_step:\n                    raise ValueError(\"Test error\")\n            except ValueError:\n                pass\n\n            assert test_step.is_error is True\n            assert \"Test error\" in test_step.output\n\n    async def test_step_parent_id_from_context(self, mock_chainlit_context):\n        \"\"\"Test that parent_id is set from context when nesting steps.\"\"\"\n        async with mock_chainlit_context:\n            async with Step(name=\"parent_step\") as parent:\n                async with Step(name=\"child_step\") as child:\n                    assert child.parent_id == parent.id\n\n    async def test_step_local_steps_tracking(self, mock_chainlit_context):\n        \"\"\"Test that local_steps tracks step hierarchy.\"\"\"\n        async with mock_chainlit_context:\n            async with Step(name=\"step1\") as step1:\n                steps = local_steps.get()\n                assert step1 in steps\n\n                async with Step(name=\"step2\") as step2:\n                    steps = local_steps.get()\n                    assert step1 in steps\n                    assert step2 in steps\n\n                # After step2 exits\n                steps = local_steps.get()\n                assert step1 in steps\n                assert step2 not in steps\n\n    async def test_step_with_none_input(self, mock_chainlit_context):\n        \"\"\"Test Step handles None input correctly.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.input = None\n\n            assert test_step.input == \"\"\n\n    async def test_step_with_none_output(self, mock_chainlit_context):\n        \"\"\"Test Step handles None output correctly.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.output = None\n\n            assert test_step.output == \"\"\n\n    async def test_step_with_list_content(self, mock_chainlit_context):\n        \"\"\"Test Step handles list content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.output = [1, 2, 3, \"four\"]\n\n            assert \"[\" in test_step.output\n            assert \"1\" in test_step.output\n            assert \"four\" in test_step.output\n            assert test_step.language == \"json\"\n\n    async def test_step_with_tuple_content(self, mock_chainlit_context):\n        \"\"\"Test Step handles tuple content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            test_step.output = (\"a\", \"b\", \"c\")\n\n            assert test_step.output != \"\"\n            assert test_step.language == \"json\"\n\n\n@pytest.mark.asyncio\nclass TestStepDecorator:\n    \"\"\"Test suite for the @step decorator.\"\"\"\n\n    async def test_step_decorator_async_function(self, mock_chainlit_context):\n        \"\"\"Test @step decorator on async function.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step(name=\"async_step\", type=\"tool\")\n            async def async_function(x: int, y: int):\n                return x + y\n\n            result = await async_function(2, 3)\n\n            assert result == 5\n            ctx.emitter.send_step.assert_called()\n\n    async def test_step_decorator_sync_function(self, mock_chainlit_context):\n        \"\"\"Test @step decorator on sync function.\"\"\"\n        async with mock_chainlit_context:\n\n            @step(name=\"sync_step\", type=\"tool\")\n            def sync_function(x: int, y: int):\n                return x + y\n\n            result = sync_function(2, 3)\n\n            assert result == 5\n\n    async def test_step_decorator_uses_function_name(self, mock_chainlit_context):\n        \"\"\"Test that decorator uses function name when name not provided.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step(type=\"tool\")\n            async def my_custom_function():\n                return \"result\"\n\n            await my_custom_function()\n\n            # Check that step was created with function name\n            call_args = ctx.emitter.send_step.call_args\n            step_dict = call_args[0][0]\n            assert step_dict[\"name\"] == \"my_custom_function\"\n\n    async def test_step_decorator_captures_input(self, mock_chainlit_context):\n        \"\"\"Test that decorator captures function arguments as input.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step(name=\"test_step\")\n            async def function_with_args(a: str, b: int, c: bool = True):\n                return \"done\"\n\n            await function_with_args(\"hello\", 42, c=False)\n\n            # Verify send_step was called (input is set during step execution)\n            ctx.emitter.send_step.assert_called()\n\n    async def test_step_decorator_captures_output(self, mock_chainlit_context):\n        \"\"\"Test that decorator captures function return value as output.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step(name=\"test_step\")\n            async def function_with_return():\n                return {\"status\": \"success\", \"value\": 123}\n\n            await function_with_return()\n\n            call_args = ctx.emitter.update_step.call_args\n            step_dict = call_args[0][0]\n            assert \"status\" in step_dict[\"output\"]\n            assert \"success\" in step_dict[\"output\"]\n\n    async def test_step_decorator_handles_exception(self, mock_chainlit_context):\n        \"\"\"Test that decorator handles exceptions in wrapped function.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step(name=\"error_step\")\n            async def function_with_error():\n                raise ValueError(\"Something went wrong\")\n\n            try:\n                await function_with_error()\n            except ValueError:\n                pass\n\n            call_args = ctx.emitter.update_step.call_args\n            step_dict = call_args[0][0]\n            assert step_dict[\"isError\"] is True\n            assert \"Something went wrong\" in step_dict[\"output\"]\n\n    async def test_step_decorator_with_metadata(self, mock_chainlit_context):\n        \"\"\"Test decorator with metadata parameter.\"\"\"\n        async with mock_chainlit_context as ctx:\n            metadata = {\"version\": \"1.0\", \"author\": \"test\"}\n\n            @step(name=\"test_step\", metadata=metadata)\n            async def function_with_metadata():\n                return \"result\"\n\n            await function_with_metadata()\n\n            call_args = ctx.emitter.send_step.call_args\n            step_dict = call_args[0][0]\n            assert step_dict[\"metadata\"] == metadata\n\n    async def test_step_decorator_with_tags(self, mock_chainlit_context):\n        \"\"\"Test decorator with tags parameter.\"\"\"\n        async with mock_chainlit_context as ctx:\n            tags = [\"important\", \"production\"]\n\n            @step(name=\"test_step\", tags=tags)\n            async def function_with_tags():\n                return \"result\"\n\n            await function_with_tags()\n\n            call_args = ctx.emitter.send_step.call_args\n            step_dict = call_args[0][0]\n            assert step_dict[\"tags\"] == tags\n\n    async def test_step_decorator_without_parentheses(self, mock_chainlit_context):\n        \"\"\"Test @step decorator without parentheses.\"\"\"\n        async with mock_chainlit_context as ctx:\n\n            @step\n            async def simple_function():\n                return \"result\"\n\n            result = await simple_function()\n\n            assert result == \"result\"\n            ctx.emitter.send_step.assert_called()\n\n\n@pytest.mark.asyncio\nclass TestStepHelperFunctions:\n    \"\"\"Test suite for Step helper functions.\"\"\"\n\n    def test_flatten_args_kwargs(self):\n        \"\"\"Test flatten_args_kwargs function.\"\"\"\n\n        def sample_func(a, b, c=10, d=20):\n            pass\n\n        result = flatten_args_kwargs(sample_func, (1, 2), {\"d\": 30})\n\n        assert result[\"a\"] == 1\n        assert result[\"b\"] == 2\n        assert result[\"c\"] == 10  # default value\n        assert result[\"d\"] == 30\n\n    def test_flatten_args_kwargs_with_all_kwargs(self):\n        \"\"\"Test flatten_args_kwargs with all keyword arguments.\"\"\"\n\n        def sample_func(x, y, z):\n            pass\n\n        result = flatten_args_kwargs(sample_func, (), {\"x\": 1, \"y\": 2, \"z\": 3})\n\n        assert result == {\"x\": 1, \"y\": 2, \"z\": 3}\n\n    async def test_stub_step(self, mock_chainlit_context):\n        \"\"\"Test stub_step function creates minimal step dict.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test_step\", type=\"tool\")\n            test_step.parent_id = \"parent_123\"\n            test_step.input = \"full input\"\n            test_step.output = \"full output\"\n\n            stub = stub_step(test_step)\n\n            assert stub[\"name\"] == \"test_step\"\n            assert stub[\"type\"] == \"tool\"\n            assert stub[\"id\"] == test_step.id\n            assert stub[\"parentId\"] == \"parent_123\"\n            assert stub[\"threadId\"] == test_step.thread_id\n            assert stub[\"input\"] == \"\"  # Stubbed\n            assert stub[\"output\"] == \"\"  # Stubbed\n\n    async def test_check_add_step_in_cot_hidden(self, mock_chainlit_context):\n        \"\"\"Test check_add_step_in_cot with hidden COT.\"\"\"\n        async with mock_chainlit_context:\n            step_module = sys.modules[\"chainlit.step\"]\n            with patch.object(step_module, \"config\") as mock_config:\n                mock_config.ui.cot = \"hidden\"\n\n                # Message types should be added\n                message_step = Step(name=\"test\", type=\"assistant_message\")\n                assert check_add_step_in_cot(message_step) is True\n\n                # Non-message types should not be added\n                tool_step = Step(name=\"test\", type=\"tool\")\n                assert check_add_step_in_cot(tool_step) is False\n\n    async def test_check_add_step_in_cot_visible(self, mock_chainlit_context):\n        \"\"\"Test check_add_step_in_cot with visible COT.\"\"\"\n        async with mock_chainlit_context:\n            step_module = sys.modules[\"chainlit.step\"]\n            with patch.object(step_module, \"config\") as mock_config:\n                mock_config.ui.cot = \"visible\"\n\n                # All steps should be added\n                tool_step = Step(name=\"test\", type=\"tool\")\n                assert check_add_step_in_cot(tool_step) is True\n\n\n@pytest.mark.asyncio\nclass TestStepEdgeCases:\n    \"\"\"Test suite for Step edge cases and error handling.\"\"\"\n\n    async def test_step_with_non_serializable_content(self, mock_chainlit_context):\n        \"\"\"Test Step handles non-JSON-serializable content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n\n            class NonSerializable:\n                pass\n\n            test_step.output = NonSerializable()\n\n            # Should convert to string\n            assert isinstance(test_step.output, str)\n            assert test_step.language == \"text\"\n\n    async def test_step_with_very_long_content(self, mock_chainlit_context):\n        \"\"\"Test Step handles very long content.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n            long_text = \"x\" * 10000\n\n            test_step.output = long_text\n\n            assert len(test_step.output) == 10000\n\n    async def test_step_multiple_updates(self, mock_chainlit_context):\n        \"\"\"Test calling update() multiple times.\"\"\"\n        async with mock_chainlit_context as ctx:\n            test_step = Step(name=\"test\")\n\n            await test_step.update()\n            await test_step.update()\n            await test_step.update()\n\n            assert ctx.emitter.update_step.call_count == 3\n\n    async def test_step_id_uniqueness(self, mock_chainlit_context):\n        \"\"\"Test that each Step gets a unique ID.\"\"\"\n        async with mock_chainlit_context:\n            step1 = Step(name=\"step1\")\n            step2 = Step(name=\"step2\")\n            step3 = Step(name=\"step3\")\n\n            ids = {step1.id, step2.id, step3.id}\n            assert len(ids) == 3  # All unique\n\n    async def test_step_with_custom_thread_id(self, mock_chainlit_context):\n        \"\"\"Test Step with custom thread_id.\"\"\"\n        async with mock_chainlit_context:\n            custom_thread_id = \"custom_thread_123\"\n            test_step = Step(name=\"test\", thread_id=custom_thread_id)\n\n            assert test_step.thread_id == custom_thread_id\n\n    async def test_step_fail_on_persist_error_flag(self, mock_chainlit_context):\n        \"\"\"Test fail_on_persist_error flag behavior.\"\"\"\n        async with mock_chainlit_context:\n            test_step = Step(name=\"test\")\n\n            assert test_step.fail_on_persist_error is False\n\n            test_step.fail_on_persist_error = True\n            assert test_step.fail_on_persist_error is True\n"
  },
  {
    "path": "backend/tests/test_translations.py",
    "content": "from io import StringIO\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom chainlit.translations import compare_json_structures, lint_translation_json\n\n\nclass TestCompareJsonStructures:\n    \"\"\"Test suite for compare_json_structures function.\"\"\"\n\n    def test_compare_identical_structures(self):\n        \"\"\"Test comparing identical JSON structures.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\"}\n        to_compare = {\"key1\": \"value1\", \"key2\": \"value2\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_with_missing_keys(self):\n        \"\"\"Test when to_compare is missing keys.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\", \"key3\": \"value3\"}\n        to_compare = {\"key1\": \"value1\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert \"❌ Missing key: 'key2'\" in errors\n        assert \"❌ Missing key: 'key3'\" in errors\n\n    def test_compare_with_extra_keys(self):\n        \"\"\"Test when to_compare has extra keys.\"\"\"\n        truth = {\"key1\": \"value1\"}\n        to_compare = {\"key1\": \"value1\", \"key2\": \"value2\", \"key3\": \"value3\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert \"⚠️ Extra key: 'key2'\" in errors\n        assert \"⚠️ Extra key: 'key3'\" in errors\n\n    def test_compare_with_both_missing_and_extra_keys(self):\n        \"\"\"Test when there are both missing and extra keys.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\"}\n        to_compare = {\"key1\": \"value1\", \"key3\": \"value3\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert any(\"Extra key: 'key3'\" in e for e in errors)\n        assert any(\"Missing key: 'key2'\" in e for e in errors)\n\n    def test_compare_nested_structures(self):\n        \"\"\"Test comparing nested JSON structures.\"\"\"\n        truth = {\"level1\": {\"level2\": {\"key\": \"value\"}}}\n        to_compare = {\"level1\": {\"level2\": {\"key\": \"value\"}}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_nested_with_missing_keys(self):\n        \"\"\"Test nested structures with missing keys.\"\"\"\n        truth = {\"level1\": {\"key1\": \"value1\", \"key2\": \"value2\"}}\n        to_compare = {\"level1\": {\"key1\": \"value1\"}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"❌ Missing key: 'level1.key2'\" in errors\n\n    def test_compare_nested_with_extra_keys(self):\n        \"\"\"Test nested structures with extra keys.\"\"\"\n        truth = {\"level1\": {\"key1\": \"value1\"}}\n        to_compare = {\"level1\": {\"key1\": \"value1\", \"key2\": \"value2\"}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"⚠️ Extra key: 'level1.key2'\" in errors\n\n    def test_compare_deeply_nested_structures(self):\n        \"\"\"Test deeply nested structures.\"\"\"\n        truth = {\"a\": {\"b\": {\"c\": {\"d\": \"value\"}}}}\n        to_compare = {\"a\": {\"b\": {\"c\": {}}}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"❌ Missing key: 'a.b.c.d'\" in errors\n\n    def test_compare_structure_mismatch_dict_vs_value(self):\n        \"\"\"Test when one is dict and other is value.\"\"\"\n        truth = {\"key\": {\"nested\": \"value\"}}\n        to_compare = {\"key\": \"not_a_dict\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"❌ Structure mismatch at: 'key'\" in errors\n\n    def test_compare_structure_mismatch_value_vs_dict(self):\n        \"\"\"Test when truth is value and to_compare is dict.\"\"\"\n        truth = {\"key\": \"value\"}\n        to_compare = {\"key\": {\"nested\": \"value\"}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"❌ Structure mismatch at: 'key'\" in errors\n\n    def test_compare_with_non_dict_input_truth(self):\n        \"\"\"Test error when truth is not a dict.\"\"\"\n        with pytest.raises(ValueError, match=\"Both inputs must be dictionaries\"):\n            compare_json_structures(\"not_a_dict\", {})\n\n    def test_compare_with_non_dict_input_to_compare(self):\n        \"\"\"Test error when to_compare is not a dict.\"\"\"\n        with pytest.raises(ValueError, match=\"Both inputs must be dictionaries\"):\n            compare_json_structures({}, \"not_a_dict\")\n\n    def test_compare_with_both_non_dict_inputs(self):\n        \"\"\"Test error when both inputs are not dicts.\"\"\"\n        with pytest.raises(ValueError, match=\"Both inputs must be dictionaries\"):\n            compare_json_structures(\"not_a_dict\", \"also_not_a_dict\")\n\n    def test_compare_empty_dicts(self):\n        \"\"\"Test comparing empty dictionaries.\"\"\"\n        truth = {}\n        to_compare = {}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_empty_truth_with_data(self):\n        \"\"\"Test when truth is empty but to_compare has data.\"\"\"\n        truth = {}\n        to_compare = {\"key1\": \"value1\", \"key2\": \"value2\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert all(\"Extra key\" in e for e in errors)\n\n    def test_compare_empty_to_compare_with_data(self):\n        \"\"\"Test when to_compare is empty but truth has data.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\"}\n        to_compare = {}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert all(\"Missing key\" in e for e in errors)\n\n    def test_compare_with_different_value_types(self):\n        \"\"\"Test that different value types at leaf nodes don't cause errors.\"\"\"\n        truth = {\"key1\": \"string\", \"key2\": 123, \"key3\": True}\n        to_compare = {\"key1\": \"different\", \"key2\": 456, \"key3\": False}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        # Structure matches, so no errors (values are not compared)\n        assert errors == []\n\n    def test_compare_complex_nested_structure(self):\n        \"\"\"Test complex nested structure with multiple levels.\"\"\"\n        truth = {\n            \"app\": {\n                \"title\": \"My App\",\n                \"settings\": {\"theme\": \"dark\", \"language\": \"en\"},\n            },\n            \"user\": {\"name\": \"John\", \"preferences\": {\"notifications\": True}},\n        }\n        to_compare = {\n            \"app\": {\n                \"title\": \"My App\",\n                \"settings\": {\"theme\": \"light\"},  # Missing 'language'\n            },\n            \"user\": {\n                \"name\": \"Jane\",\n                \"preferences\": {\"notifications\": False, \"extra\": \"value\"},  # Extra key\n            },\n        }\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 2\n        assert any(\"Missing key: 'app.settings.language'\" in e for e in errors)\n        assert any(\"Extra key: 'user.preferences.extra'\" in e for e in errors)\n\n    def test_compare_with_null_values(self):\n        \"\"\"Test structures with None/null values.\"\"\"\n        truth = {\"key1\": None, \"key2\": \"value\"}\n        to_compare = {\"key1\": None, \"key2\": \"value\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_with_list_values(self):\n        \"\"\"Test structures with list values (treated as leaf nodes).\"\"\"\n        truth = {\"key1\": [\"a\", \"b\", \"c\"], \"key2\": \"value\"}\n        to_compare = {\"key1\": [\"x\", \"y\"], \"key2\": \"value\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        # Lists are leaf nodes, structure matches\n        assert errors == []\n\n    def test_compare_path_formatting(self):\n        \"\"\"Test that error paths are formatted correctly.\"\"\"\n        truth = {\"a\": {\"b\": {\"c\": \"value\"}}}\n        to_compare = {\"a\": {\"b\": {}}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"a.b.c\" in errors[0]\n        assert not errors[0].startswith(\".\")\n\n\nclass TestLintTranslationJson:\n    \"\"\"Test suite for lint_translation_json function.\"\"\"\n\n    def test_lint_with_no_errors(self):\n        \"\"\"Test linting when there are no errors.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\"}\n        to_compare = {\"key1\": \"value1\", \"key2\": \"value2\"}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"test.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting test.json...\" in output\n            assert \"✅ No errors found in test.json\" in output\n\n    def test_lint_with_errors(self):\n        \"\"\"Test linting when there are errors.\"\"\"\n        truth = {\"key1\": \"value1\", \"key2\": \"value2\"}\n        to_compare = {\"key1\": \"value1\", \"key3\": \"value3\"}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"test.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting test.json...\" in output\n            assert \"Missing key: 'key2'\" in output\n            assert \"Extra key: 'key3'\" in output\n            assert \"✅ No errors found\" not in output\n\n    def test_lint_with_nested_errors(self):\n        \"\"\"Test linting with nested structure errors.\"\"\"\n        truth = {\"level1\": {\"key1\": \"value1\", \"key2\": \"value2\"}}\n        to_compare = {\"level1\": {\"key1\": \"value1\"}}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"nested.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting nested.json...\" in output\n            assert \"Missing key: 'level1.key2'\" in output\n\n    def test_lint_with_structure_mismatch(self):\n        \"\"\"Test linting with structure mismatch.\"\"\"\n        truth = {\"key\": {\"nested\": \"value\"}}\n        to_compare = {\"key\": \"not_nested\"}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"mismatch.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting mismatch.json...\" in output\n            assert \"Structure mismatch at: 'key'\" in output\n\n    def test_lint_with_multiple_errors(self):\n        \"\"\"Test linting with multiple types of errors.\"\"\"\n        truth = {\n            \"key1\": \"value1\",\n            \"key2\": {\"nested\": \"value\"},\n            \"key3\": \"value3\",\n        }\n        to_compare = {\n            \"key1\": \"value1\",\n            \"key2\": \"not_nested\",\n            \"key4\": \"extra\",\n        }\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"multi.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting multi.json...\" in output\n            assert \"Structure mismatch\" in output\n            assert \"Missing key: 'key3'\" in output\n            assert \"Extra key: 'key4'\" in output\n\n    def test_lint_output_format(self):\n        \"\"\"Test that lint output is properly formatted.\"\"\"\n        truth = {\"key1\": \"value1\"}\n        to_compare = {\"key2\": \"value2\"}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"format.json\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            # Check that output starts with newline and linting message\n            lines = output.strip().split(\"\\n\")\n            assert \"Linting format.json...\" in lines[0]\n            assert len(lines) >= 2  # At least linting message + errors\n\n\nclass TestTranslationsEdgeCases:\n    \"\"\"Test suite for edge cases in translations module.\"\"\"\n\n    def test_compare_with_numeric_keys(self):\n        \"\"\"Test structures with numeric keys (as strings).\"\"\"\n        truth = {\"1\": \"value1\", \"2\": \"value2\"}\n        to_compare = {\"1\": \"value1\", \"2\": \"value2\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_with_special_characters_in_keys(self):\n        \"\"\"Test keys with special characters.\"\"\"\n        truth = {\"key-1\": \"value\", \"key_2\": \"value\", \"key.3\": \"value\"}\n        to_compare = {\"key-1\": \"value\", \"key_2\": \"value\", \"key.3\": \"value\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_with_unicode_keys(self):\n        \"\"\"Test keys with unicode characters.\"\"\"\n        truth = {\"键\": \"value\", \"clé\": \"value\", \"مفتاح\": \"value\"}\n        to_compare = {\"键\": \"value\", \"clé\": \"value\", \"مفتاح\": \"value\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_compare_very_deeply_nested(self):\n        \"\"\"Test very deeply nested structures.\"\"\"\n        truth = {\"a\": {\"b\": {\"c\": {\"d\": {\"e\": {\"f\": \"value\"}}}}}}\n        to_compare = {\"a\": {\"b\": {\"c\": {\"d\": {\"e\": {}}}}}}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert len(errors) == 1\n        assert \"a.b.c.d.e.f\" in errors[0]\n\n    def test_compare_with_empty_string_values(self):\n        \"\"\"Test structures with empty string values.\"\"\"\n        truth = {\"key1\": \"\", \"key2\": \"value\"}\n        to_compare = {\"key1\": \"\", \"key2\": \"value\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        assert errors == []\n\n    def test_lint_with_empty_filename(self):\n        \"\"\"Test lint with empty filename.\"\"\"\n        truth = {\"key\": \"value\"}\n        to_compare = {\"key\": \"value\"}\n\n        with patch(\"sys.stdout\", new=StringIO()) as fake_out:\n            lint_translation_json(\"\", truth, to_compare)\n            output = fake_out.getvalue()\n\n            assert \"Linting ...\" in output\n\n    def test_compare_preserves_error_order(self):\n        \"\"\"Test that errors are reported in a consistent order.\"\"\"\n        truth = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\"}\n        to_compare = {\"d\": \"4\", \"e\": \"5\"}\n\n        errors = compare_json_structures(truth, to_compare)\n\n        # Should have 2 extra keys and 3 missing keys\n        assert len(errors) == 5\n        extra_errors = [e for e in errors if \"Extra\" in e]\n        missing_errors = [e for e in errors if \"Missing\" in e]\n        assert len(extra_errors) == 2\n        assert len(missing_errors) == 3\n"
  },
  {
    "path": "backend/tests/test_user_session.py",
    "content": "async def test_user_session_set_get(mock_chainlit_context, user_session):\n    async with mock_chainlit_context as context:\n        # Test setting a value\n        user_session.set(\"test_key\", \"test_value\")\n\n        # Test getting the value\n        assert user_session.get(\"test_key\") == \"test_value\"\n\n        # Test getting a default value for a non-existent key\n        assert user_session.get(\"non_existent_key\", \"default\") == \"default\"\n\n        # Test getting session-related values\n        assert user_session.get(\"id\") == context.session.id\n        assert user_session.get(\"env\") == context.session.user_env\n"
  },
  {
    "path": "backend/tests/test_utils.py",
    "content": "import os\nimport tempfile\nfrom datetime import datetime, timezone\nfrom unittest.mock import AsyncMock, patch\n\nimport click\nimport pytest\n\nfrom chainlit.utils import (\n    check_file,\n    check_module_version,\n    make_module_getattr,\n    timestamp_utc,\n    utc_now,\n    wrap_user_function,\n)\n\n\nclass TestUtcNow:\n    \"\"\"Test suite for utc_now function.\"\"\"\n\n    def test_utc_now_returns_string(self):\n        \"\"\"Test that utc_now returns a string.\"\"\"\n        result = utc_now()\n        assert isinstance(result, str)\n\n    def test_utc_now_ends_with_z(self):\n        \"\"\"Test that utc_now returns ISO format with Z suffix.\"\"\"\n        result = utc_now()\n        assert result.endswith(\"Z\")\n\n    def test_utc_now_is_iso_format(self):\n        \"\"\"Test that utc_now returns valid ISO format.\"\"\"\n        result = utc_now()\n        # Remove the Z and parse\n        dt_str = result[:-1]\n        # Should be parseable as ISO format\n        datetime.fromisoformat(dt_str)\n\n    def test_utc_now_is_current_time(self):\n        \"\"\"Test that utc_now returns approximately current time.\"\"\"\n        before = datetime.now(timezone.utc).replace(tzinfo=None)\n        result = utc_now()\n        after = datetime.now(timezone.utc).replace(tzinfo=None)\n\n        # Parse the result (naive datetime)\n        result_dt = datetime.fromisoformat(result[:-1])\n\n        # Should be between before and after (with some tolerance for microseconds)\n        assert (\n            before.replace(microsecond=0) <= result_dt <= after.replace(microsecond=0)\n            or before <= result_dt <= after\n        )\n\n    def test_utc_now_multiple_calls(self):\n        \"\"\"Test that multiple calls to utc_now return different values.\"\"\"\n        result1 = utc_now()\n        result2 = utc_now()\n\n        # Results should be very close but might differ\n        assert isinstance(result1, str)\n        assert isinstance(result2, str)\n\n\nclass TestTimestampUtc:\n    \"\"\"Test suite for timestamp_utc function.\"\"\"\n\n    def test_timestamp_utc_returns_string(self):\n        \"\"\"Test that timestamp_utc returns a string.\"\"\"\n        result = timestamp_utc(1234567890.0)\n        assert isinstance(result, str)\n\n    def test_timestamp_utc_ends_with_z(self):\n        \"\"\"Test that timestamp_utc returns ISO format with Z suffix.\"\"\"\n        result = timestamp_utc(1234567890.0)\n        assert result.endswith(\"Z\")\n\n    def test_timestamp_utc_converts_correctly(self):\n        \"\"\"Test that timestamp_utc converts timestamp correctly.\"\"\"\n        # Known timestamp: 2009-02-13 23:31:30 UTC\n        timestamp = 1234567890.0\n        result = timestamp_utc(timestamp)\n\n        # Parse and verify\n        dt = datetime.fromisoformat(result[:-1])\n        assert dt.year == 2009\n        assert dt.month == 2\n        assert dt.day == 13\n\n    def test_timestamp_utc_with_zero(self):\n        \"\"\"Test timestamp_utc with epoch (0).\"\"\"\n        result = timestamp_utc(0.0)\n        dt = datetime.fromisoformat(result[:-1])\n        assert dt.year == 1970\n        assert dt.month == 1\n        assert dt.day == 1\n\n    def test_timestamp_utc_with_fractional_seconds(self):\n        \"\"\"Test timestamp_utc with fractional seconds.\"\"\"\n        timestamp = 1234567890.123456\n        result = timestamp_utc(timestamp)\n\n        # Should be valid ISO format\n        dt = datetime.fromisoformat(result[:-1])\n        assert isinstance(dt, datetime)\n\n    def test_timestamp_utc_with_negative_timestamp(self):\n        \"\"\"Test timestamp_utc with negative timestamp (before epoch).\"\"\"\n        # 1969-12-31 23:00:00 UTC\n        timestamp = -3600.0\n        result = timestamp_utc(timestamp)\n\n        dt = datetime.fromisoformat(result[:-1])\n        assert dt.year == 1969\n\n\n@pytest.mark.asyncio\nclass TestWrapUserFunction:\n    \"\"\"Test suite for wrap_user_function.\"\"\"\n\n    async def test_wrap_user_function_with_sync_function(self, mock_chainlit_context):\n        \"\"\"Test wrapping a synchronous function.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func(a, b):\n                return a + b\n\n            wrapped = wrap_user_function(user_func)\n            result = await wrapped(5, 3)\n\n            assert result == 8\n\n    async def test_wrap_user_function_with_async_function(self, mock_chainlit_context):\n        \"\"\"Test wrapping an asynchronous function.\"\"\"\n        async with mock_chainlit_context:\n\n            async def user_func(x, y):\n                return x * y\n\n            wrapped = wrap_user_function(user_func)\n            result = await wrapped(4, 7)\n\n            assert result == 28\n\n    async def test_wrap_user_function_with_no_args(self, mock_chainlit_context):\n        \"\"\"Test wrapping a function with no arguments.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func():\n                return \"hello\"\n\n            wrapped = wrap_user_function(user_func)\n            result = await wrapped()\n\n            assert result == \"hello\"\n\n    async def test_wrap_user_function_with_task(self, mock_chainlit_context):\n        \"\"\"Test wrapping a function with task management.\"\"\"\n        async with mock_chainlit_context as ctx:\n            ctx.emitter.task_start = AsyncMock()\n            ctx.emitter.task_end = AsyncMock()\n\n            def user_func(value):\n                return value * 2\n\n            wrapped = wrap_user_function(user_func, with_task=True)\n            result = await wrapped(10)\n\n            assert result == 20\n            ctx.emitter.task_start.assert_called_once()\n            ctx.emitter.task_end.assert_called_once()\n\n    async def test_wrap_user_function_handles_exception(self, mock_chainlit_context):\n        \"\"\"Test that wrapped function handles exceptions.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func():\n                raise ValueError(\"Test error\")\n\n            wrapped = wrap_user_function(user_func)\n            result = await wrapped()\n\n            # Should return None when exception occurs\n            assert result is None\n\n    async def test_wrap_user_function_with_task_handles_exception(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test that wrapped function with task handles exceptions.\"\"\"\n        async with mock_chainlit_context as ctx:\n            ctx.emitter.task_start = AsyncMock()\n            ctx.emitter.task_end = AsyncMock()\n\n            def user_func():\n                raise ValueError(\"Test error\")\n\n            with patch(\"chainlit.utils.logger\") as mock_logger:\n                wrapped = wrap_user_function(user_func, with_task=True)\n                result = await wrapped()\n\n                assert result is None\n                ctx.emitter.task_start.assert_called_once()\n                ctx.emitter.task_end.assert_called_once()\n                mock_logger.exception.assert_called_once()\n\n    async def test_wrap_user_function_preserves_function_metadata(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test that wrapping preserves function metadata.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func(a, b):\n                \"\"\"Test function docstring.\"\"\"\n                return a + b\n\n            wrapped = wrap_user_function(user_func)\n\n            assert wrapped.__name__ == \"user_func\"\n            assert wrapped.__doc__ == \"Test function docstring.\"\n\n    async def test_wrap_user_function_with_kwargs(self, mock_chainlit_context):\n        \"\"\"Test wrapping a function and calling with positional args.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func(x, y, z):\n                return x + y + z\n\n            wrapped = wrap_user_function(user_func)\n            result = await wrapped(1, 2, 3)\n\n            assert result == 6\n\n\nclass TestMakeModuleGetattr:\n    \"\"\"Test suite for make_module_getattr.\"\"\"\n\n    def test_make_module_getattr_creates_function(self):\n        \"\"\"Test that make_module_getattr creates a function.\"\"\"\n        registry = {\"SomeClass\": \"some.module\"}\n        getattr_func = make_module_getattr(registry)\n\n        assert callable(getattr_func)\n\n    def test_make_module_getattr_imports_module(self):\n        \"\"\"Test that the created function imports modules.\"\"\"\n        # Use a real module for testing\n        registry = {\"datetime\": \"datetime\"}\n        getattr_func = make_module_getattr(registry)\n\n        result = getattr_func(\"datetime\")\n        assert result is datetime\n\n    def test_make_module_getattr_with_nested_module(self):\n        \"\"\"Test with nested module path.\"\"\"\n        registry = {\"timezone\": \"datetime\"}\n        getattr_func = make_module_getattr(registry)\n\n        result = getattr_func(\"timezone\")\n        assert result is timezone\n\n\nclass TestCheckModuleVersion:\n    \"\"\"Test suite for check_module_version.\"\"\"\n\n    def test_check_module_version_with_installed_module(self):\n        \"\"\"Test checking version of an installed module.\"\"\"\n        # pytest should be installed\n        result = check_module_version(\"pytest\", \"1.0.0\")\n        assert result is True\n\n    def test_check_module_version_with_higher_required_version(self):\n        \"\"\"Test with a required version higher than installed.\"\"\"\n        # Require an impossibly high version\n        result = check_module_version(\"pytest\", \"999.0.0\")\n        assert result is False\n\n    def test_check_module_version_with_nonexistent_module(self):\n        \"\"\"Test with a module that doesn't exist.\"\"\"\n        result = check_module_version(\"nonexistent_module_xyz\", \"1.0.0\")\n        assert result is False\n\n    def test_check_module_version_exact_match(self):\n        \"\"\"Test with exact version match.\"\"\"\n        # Get actual pytest version\n        result = check_module_version(\"pytest\", pytest.__version__)\n        assert result is True\n\n    def test_check_module_version_with_builtin_module(self):\n        \"\"\"Test with a builtin module that has no __version__.\"\"\"\n        # os module doesn't have __version__\n        with pytest.raises(AttributeError):\n            check_module_version(\"os\", \"1.0.0\")\n\n\nclass TestCheckFile:\n    \"\"\"Test suite for check_file function.\"\"\"\n\n    def test_check_file_with_valid_py_file(self):\n        \"\"\"Test check_file with a valid .py file.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\".py\", delete=False) as f:\n            temp_file = f.name\n\n        try:\n            # Should not raise any exception\n            check_file(temp_file)\n        finally:\n            os.unlink(temp_file)\n\n    def test_check_file_with_valid_py3_file(self):\n        \"\"\"Test check_file with a valid .py3 file.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\".py3\", delete=False) as f:\n            temp_file = f.name\n\n        try:\n            # Should not raise any exception\n            check_file(temp_file)\n        finally:\n            os.unlink(temp_file)\n\n    def test_check_file_with_invalid_extension(self):\n        \"\"\"Test check_file with invalid file extension.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\".txt\", delete=False) as f:\n            temp_file = f.name\n\n        try:\n            with pytest.raises(click.BadArgumentUsage) as exc_info:\n                check_file(temp_file)\n            assert \".txt\" in str(exc_info.value)\n        finally:\n            os.unlink(temp_file)\n\n    def test_check_file_with_no_extension(self):\n        \"\"\"Test check_file with file that has no extension.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\"\", delete=False) as f:\n            temp_file = f.name\n\n        try:\n            with pytest.raises(click.BadArgumentUsage) as exc_info:\n                check_file(temp_file)\n            assert \"no extension\" in str(exc_info.value)\n        finally:\n            os.unlink(temp_file)\n\n    def test_check_file_with_nonexistent_file(self):\n        \"\"\"Test check_file with a file that doesn't exist.\"\"\"\n        nonexistent_file = \"/path/to/nonexistent/file.py\"\n\n        with pytest.raises(click.BadParameter) as exc_info:\n            check_file(nonexistent_file)\n        assert \"does not exist\" in str(exc_info.value)\n\n    def test_check_file_with_json_extension(self):\n        \"\"\"Test check_file with .json extension.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\".json\", delete=False) as f:\n            temp_file = f.name\n\n        try:\n            with pytest.raises(click.BadArgumentUsage) as exc_info:\n                check_file(temp_file)\n            assert \".json\" in str(exc_info.value)\n        finally:\n            os.unlink(temp_file)\n\n\nclass TestUtilsEdgeCases:\n    \"\"\"Test suite for utils edge cases.\"\"\"\n\n    def test_utc_now_format_consistency(self):\n        \"\"\"Test that utc_now format is consistent across calls.\"\"\"\n        results = [utc_now() for _ in range(5)]\n\n        for result in results:\n            # All should have same format\n            assert result.endswith(\"Z\")\n            assert \"T\" in result\n            # Should be parseable\n            datetime.fromisoformat(result[:-1])\n\n    def test_timestamp_utc_with_large_timestamp(self):\n        \"\"\"Test timestamp_utc with very large timestamp (far future).\"\"\"\n        # Year 2100\n        timestamp = 4102444800.0\n        result = timestamp_utc(timestamp)\n\n        dt = datetime.fromisoformat(result[:-1])\n        assert dt.year == 2100\n\n    @pytest.mark.asyncio\n    async def test_wrap_user_function_with_multiple_exceptions(\n        self, mock_chainlit_context\n    ):\n        \"\"\"Test wrapped function handles different exception types.\"\"\"\n        async with mock_chainlit_context:\n            exceptions = [ValueError(\"error1\"), TypeError(\"error2\"), KeyError(\"error3\")]\n\n            for exc in exceptions:\n\n                def user_func():\n                    raise exc\n\n                with patch(\"chainlit.utils.logger\"):\n                    wrapped = wrap_user_function(user_func)\n                    result = await wrapped()\n                    assert result is None\n\n    def test_check_file_with_relative_path(self):\n        \"\"\"Test check_file with relative path.\"\"\"\n        # Create a temp file in current directory\n        with tempfile.NamedTemporaryFile(suffix=\".py\", delete=False, dir=\".\") as f:\n            temp_file = os.path.basename(f.name)\n\n        try:\n            # Should work with relative path\n            check_file(temp_file)\n        finally:\n            os.unlink(temp_file)\n\n    def test_check_file_with_absolute_path(self):\n        \"\"\"Test check_file with absolute path.\"\"\"\n        with tempfile.NamedTemporaryFile(suffix=\".py\", delete=False) as f:\n            temp_file = os.path.abspath(f.name)\n\n        try:\n            # Should work with absolute path\n            check_file(temp_file)\n        finally:\n            os.unlink(temp_file)\n\n    def test_make_module_getattr_with_empty_registry(self):\n        \"\"\"Test make_module_getattr with empty registry.\"\"\"\n        registry = {}\n        getattr_func = make_module_getattr(registry)\n\n        with pytest.raises(KeyError):\n            getattr_func(\"nonexistent\")\n\n    @pytest.mark.asyncio\n    async def test_wrap_user_function_with_default_args(self, mock_chainlit_context):\n        \"\"\"Test wrapping function with default arguments.\"\"\"\n        async with mock_chainlit_context:\n\n            def user_func(a, b=10):\n                return a + b\n\n            wrapped = wrap_user_function(user_func)\n\n            # Call with only required arg\n            result = await wrapped(5)\n            assert result == 15\n"
  },
  {
    "path": "cypress/e2e/action/main.py",
    "content": "import chainlit as cl\n\n\n@cl.action_callback(\"test action\")\nasync def on_test_action():\n    await cl.Message(content=\"Executed test action!\").send()\n\n\n@cl.action_callback(\"removable action\")\nasync def on_removable_action(action: cl.Action):\n    await cl.Message(content=\"Executed removable action!\").send()\n    await action.remove()\n\n\n@cl.action_callback(\"multiple actions\")\nasync def on_multiple_actions(action: cl.Action):\n    await cl.Message(content=f\"Action(id={action.id}) has been removed!\").send()\n    await action.remove()\n\n\n@cl.action_callback(\"all actions removed\")\nasync def on_all_actions_removed(_: cl.Action):\n    await cl.Message(content=\"All actions have been removed!\").send()\n    to_remove = cl.user_session.get(\"to_remove\")  # type: cl.Message\n    await to_remove.remove_actions()\n\n\n@cl.on_chat_start\nasync def main():\n    actions = [\n        cl.Action(id=\"test-action\", name=\"test action\", payload={\"value\": \"test\"}),\n        cl.Action(\n            id=\"removable-action\", name=\"removable action\", payload={\"value\": \"test\"}\n        ),\n        cl.Action(\n            id=\"label-action\",\n            name=\"label action\",\n            payload={\"value\": \"test\"},\n            label=\"Test Label\",\n        ),\n        cl.Action(\n            id=\"multiple-action-one\",\n            name=\"multiple actions\",\n            payload={\"value\": \"multiple action one\"},\n            label=\"multiple action one\",\n        ),\n        cl.Action(\n            id=\"multiple-action-two\",\n            name=\"multiple actions\",\n            payload={\"value\": \"multiple action two\"},\n            label=\"multiple action two\",\n        ),\n        cl.Action(\n            id=\"all-actions-removed\",\n            name=\"all actions removed\",\n            payload={\"value\": \"test\"},\n        ),\n    ]\n    message = cl.Message(\"Hello, this is a test message!\", actions=actions)\n    cl.user_session.set(\"to_remove\", message)\n    await message.send()\n\n    result = await cl.AskActionMessage(\n        content=\"Please, pick an action!\",\n        actions=[\n            cl.Action(\n                id=\"first-action\",\n                name=\"first_action\",\n                payload={\"value\": \"first-action\"},\n                label=\"First action\",\n            ),\n            cl.Action(\n                id=\"second-action\",\n                name=\"second_action\",\n                payload={\"value\": \"second-action\"},\n                label=\"Second action\",\n            ),\n        ],\n    ).send()\n\n    if result is not None:\n        await cl.Message(f\"Thanks for pressing: {result['payload']['value']}\").send()\n"
  },
  {
    "path": "cypress/e2e/action/spec.cy.ts",
    "content": "describe('Action', () => {\n  it('should correctly execute and display actions', () => {\n    // Click on \"first action\"\n    cy.get('#first-action').should('exist');\n    cy.get('#first-action').click();\n    cy.get('.step').should('have.length', 3);\n    cy.get('.step')\n      .eq(2)\n      .should('contain', 'Thanks for pressing: first-action');\n\n    // Click on \"test action\"\n    cy.get(\"[id='test-action']\").should('exist');\n    cy.get(\"[id='test-action']\").click();\n    cy.get('.step').should('have.length', 4);\n    cy.get('.step').eq(3).should('contain', 'Executed test action!');\n    cy.get(\"[id='test-action']\").should('exist');\n\n    // Click on \"removable action\"\n    cy.get(\"[id='removable-action']\").should('exist');\n    cy.get(\"[id='removable-action']\").click();\n    cy.get('.step').should('have.length', 5);\n    cy.get('.step').eq(4).should('contain', 'Executed removable action!');\n    cy.get(\"[id='removable-action']\").should('not.exist');\n\n    cy.get('.step').should('have.length', 5);\n\n    cy.get(\"[id='multiple-action-one']\").should('exist');\n    cy.get(\"[id='multiple-action-one']\").click();\n    cy.get('.step')\n      .eq(5)\n      .should('contain', 'Action(id=multiple-action-one) has been removed!');\n    cy.get(\"[id='multiple-action-one']\").should('not.exist');\n\n    // Click on \"multiple action two\", should remove the correct action button\n    cy.get('.step').should('have.length', 6);\n    cy.get(\"[id='multiple-action-two']\").click();\n    cy.get('.step')\n      .eq(6)\n      .should('contain', 'Action(id=multiple-action-two) has been removed!');\n    cy.get(\"[id='multiple-action-two']\").should('not.exist');\n\n    // Click on \"all actions removed\", should remove all buttons\n    cy.get(\"[id='all-actions-removed']\").should('exist');\n    cy.get(\"[id='all-actions-removed']\").click();\n    cy.get('.step').eq(7).should('contain', 'All actions have been removed!');\n    cy.get(\"[id='all-actions-removed']\").should('not.exist');\n    cy.get(\"[id='test-action']\").should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/ask_custom_element/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def on_start():\n    element = cl.CustomElement(\n        name=\"JiraTicket\",\n        props={\n            \"timeout\": 20,\n            \"fields\": [\n                {\"id\": \"summary\", \"label\": \"Summary\", \"type\": \"text\", \"required\": True},\n                {\"id\": \"description\", \"label\": \"Description\", \"type\": \"textarea\"},\n                {\n                    \"id\": \"due\",\n                    \"label\": \"Due Date\",\n                    \"type\": \"date\",\n                },\n                {\n                    \"id\": \"priority\",\n                    \"label\": \"Priority\",\n                    \"type\": \"select\",\n                    \"options\": [\"Low\", \"Medium\", \"High\"],\n                    \"value\": \"Medium\",\n                    \"required\": True,\n                },\n            ],\n        },\n    )\n    res = await cl.AskElementMessage(\n        content=\"Create a new Jira ticket:\", element=element, timeout=10\n    ).send()\n    if res and res.get(\"submitted\"):\n        await cl.Message(\n            content=f\"Ticket '{res['summary']}' with priority {res['priority']} submitted\"\n        ).send()\n"
  },
  {
    "path": "cypress/e2e/ask_custom_element/public/elements/JiraTicket.jsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport React, { useEffect, useMemo, useState } from 'react';\n\nexport default function JiraTicket() {\n  const [timeLeft, setTimeLeft] = useState(props.timeout || 30);\n  const [values, setValues] = useState(() => {\n    const init = {};\n    (props.fields || []).forEach((f) => {\n      init[f.id] = f.value || '';\n    });\n    return init;\n  });\n\n  const allValid = useMemo(() => {\n    if (!props.fields) return true;\n    return props.fields.every((f) => {\n      if (!f.required) return true;\n      const val = values[f.id];\n      return val !== undefined && val !== '';\n    });\n  }, [props.fields, values]);\n\n  useEffect(() => {\n    const interval = setInterval(() => {\n      setTimeLeft((t) => (t > 0 ? t - 1 : 0));\n    }, 1000);\n    return () => clearInterval(interval);\n  }, []);\n\n  const handleChange = (id, val) => {\n    setValues((v) => ({ ...v, [id]: val }));\n  };\n\n  const renderField = (field) => {\n    const value = values[field.id];\n    switch (field.type) {\n      case 'textarea':\n        return <Textarea id={field.id} value={value} onChange={(e) => handleChange(field.id, e.target.value)} />;\n      case 'select':\n        return (\n          <Select value={value} onValueChange={(val) => handleChange(field.id, val)}>\n            <SelectTrigger id={field.id}>\n              <SelectValue placeholder={field.label} />\n            </SelectTrigger>\n            <SelectContent>\n              {field.options.map((opt) => (\n                <SelectItem key={opt} value={opt}>\n                  {opt}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        );\n      case 'date':\n        return <Input type=\"date\" id={field.id} value={value} onChange={(e) => handleChange(field.id, e.target.value)} />;\n      case 'datetime':\n        return <Input type=\"datetime-local\" id={field.id} value={value} onChange={(e) => handleChange(field.id, e.target.value)} />;\n      default:\n        return <Input id={field.id} value={value} onChange={(e) => handleChange(field.id, e.target.value)} />;\n    }\n  };\n\n  return (\n    <Card id=\"jira-ticket\" className=\"mt-4 w-full max-w-3xl grid grid-cols-2 gap-4\">\n      <CardHeader className=\"col-span-2\">\n        <CardTitle>Create JIRA Ticket</CardTitle>\n        <CardDescription>Provide details for the new issue. {timeLeft}s left</CardDescription>\n      </CardHeader>\n      <CardContent className=\"col-span-2 grid grid-cols-2 gap-4\">\n        {props.fields.map((field) => (\n          <div key={field.id} className=\"flex flex-col gap-2\">\n            <Label htmlFor={field.id}>\n              {field.label}\n              {field.required && <span className=\"text-red-500\">*</span>}\n            </Label>\n            {renderField(field)}\n          </div>\n        ))}\n      </CardContent>\n      <CardFooter className=\"col-span-2 flex justify-end gap-2\">\n        <Button id=\"ticket-cancel\" variant=\"outline\" onClick={() => cancelElement()}>\n          Cancel\n        </Button>\n        <Button\n          id=\"ticket-submit\"\n          disabled={!allValid}\n          onClick={() => submitElement(values)}\n        >\n          Submit\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "cypress/e2e/ask_custom_element/spec.cy.ts",
    "content": "describe('Ask Custom Element', () => {\n  it('should send element props to the backend', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('#ticket-submit').should('be.disabled');\n    cy.get('#summary').type('Bug fix');\n    cy.get('#description').type('Detailed description');\n    cy.get('#ticket-submit').should('not.be.disabled').click();\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Bug fix');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/ask_file/main.py",
    "content": "import aiofiles\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    files = await cl.AskFileMessage(\n        content=\"Please upload a text file to begin!\", accept=[\"text/plain\"]\n    ).send()\n    txt_file = files[0]\n\n    async with aiofiles.open(txt_file.path, encoding=\"utf-8\") as f:\n        content = await f.read()\n        await cl.Message(\n            content=f\"`Text file {txt_file.name}` uploaded, it contains {len(content)} characters!\"\n        ).send()\n\n    files = await cl.AskFileMessage(\n        content=\"Please upload a python file to begin!\",\n        accept={\n            \"text/plain\": [\".py\", \".txt\"],\n            # Some browser / os report it as text/plain but some as text/x-python when doing drag&drop\n            \"text/x-python\": [\".py\"],\n            # Or even as application/octet-stream when using the select file dialog\n            \"application/octet-stream\": [\".py\"],\n        },\n    ).send()\n    py_file = files[0]\n\n    async with aiofiles.open(py_file.path, encoding=\"utf-8\") as f:\n        content = await f.read()\n        await cl.Message(\n            content=f\"`Python file {py_file.name}` uploaded, it contains {len(content)} characters!\"\n        ).send()\n"
  },
  {
    "path": "cypress/e2e/ask_file/spec.cy.ts",
    "content": "describe('Upload file', () => {\n  it('should be able to receive and decode files', () => {\n    cy.get('#ask-upload-button').should('exist');\n\n    // Upload a text file\n    cy.fixture('state_of_the_union.txt', 'utf-8').as('txtFile');\n    cy.get('#ask-button-input').selectFile('@txtFile', { force: true });\n\n    // Sometimes the loading indicator is not shown because the file upload is too fast\n    // cy.get(\"#ask-upload-button-loading\").should(\"exist\");\n    // cy.get(\"#ask-upload-button-loading\").should(\"not.exist\");\n\n    cy.get('.step')\n      .eq(1)\n      .should(\n        'contain',\n        'Text file state_of_the_union.txt uploaded, it contains'\n      );\n\n    cy.get('#ask-upload-button').should('exist');\n\n    // Expecting a python file, cpp file upload should be rejected\n    cy.fixture('hello.cpp', 'utf-8').as('cppFile');\n    cy.get('#ask-button-input').selectFile('@cppFile', { force: true });\n\n    cy.get('.step').should('have.length', 3);\n\n    // Upload a python file\n    cy.fixture('hello.py', 'utf-8').as('pyFile');\n    cy.get('#ask-button-input').selectFile('@pyFile', { force: true });\n\n    cy.get('.step')\n      .should('have.length', 4)\n      .eq(3)\n      .should('contain', 'Python file hello.py uploaded, it contains');\n\n    cy.get('#ask-upload-button').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/ask_multiple_files/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    files = await cl.AskFileMessage(\n        content=\"Please upload from one to two python files to begin!\",\n        max_files=2,\n        accept={\n            \"text/plain\": [\".py\", \".txt\"],\n            # Some browser / os report it as text/plain but some as text/x-python when doing drag&drop\n            \"text/x-python\": [\".py\"],\n            # Or even as application/octet-stream when using the select file dialog\n            \"application/octet-stream\": [\".py\"],\n        },\n    ).send()\n\n    file_names = [file.name for file in files]\n\n    await cl.Message(\n        content=f\"{len(files)} files uploaded: {','.join(file_names)}\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/ask_multiple_files/spec.cy.ts",
    "content": "describe('Upload multiple files', () => {\n  it('should be able to receive two files', () => {\n    cy.get('#ask-upload-button').should('exist');\n\n    cy.fixture('state_of_the_union.txt', 'utf-8').as('txtFile');\n    cy.fixture('hello.py', 'utf-8').as('pyFile');\n\n    cy.get('#ask-button-input').selectFile(['@txtFile', '@pyFile'], {\n      force: true\n    });\n\n    // Sometimes the loading indicator is not shown because the file upload is too fast\n    // cy.get(\"#ask-upload-button-loading\").should(\"exist\");\n    // cy.get(\"#ask-upload-button-loading\").should(\"not.exist\");\n\n    cy.get('.step')\n      .eq(1)\n      .should('contain', '2 files uploaded: state_of_the_union.txt,hello.py');\n\n    cy.get('#ask-upload-button').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/ask_user/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    res = await cl.AskUserMessage(content=\"What is your name?\", timeout=10).send()\n    if res:\n        await cl.Message(\n            content=f\"Your name is: {res['output']}\",\n        ).send()\n"
  },
  {
    "path": "cypress/e2e/ask_user/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Ask User', () => {\n  it('should send a new message containing the user input', () => {\n    cy.get('.step').should('have.length', 1);\n    submitMessage('Jeeves');\n\n    cy.get('.step').should('have.length', 3);\n\n    cy.get('.step').eq(2).should('contain', 'Jeeves');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/audio_element/main.py",
    "content": "import os\n\nimport chainlit as cl\n\n# Get the directory where the script is located\nscript_directory = os.path.dirname(os.path.abspath(__file__))\n# Create absolute path to the audio file\naudio_path = os.path.join(script_directory, \"../../fixtures/example.mp3\")\n\n\n@cl.on_chat_start\nasync def start():\n    elements = [cl.Audio(name=\"example.mp3\", path=audio_path)]\n\n    await cl.Message(content=\"This message has an audio\", elements=elements).send()\n"
  },
  {
    "path": "cypress/e2e/audio_element/spec.cy.ts",
    "content": "describe('audio', () => {\n  it('should be able to display an audio element', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).find('.inline-audio').should('have.length', 1);\n\n    cy.get('.inline-audio audio')\n      .then(($el) => {\n        const audioElement = $el.get(0) as HTMLAudioElement;\n        return audioElement.play().then(() => {\n          return audioElement.duration;\n        });\n      })\n      .should('be.greaterThan', 0);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/auth/main.py",
    "content": "import os\nfrom uuid import uuid4\n\nimport chainlit as cl\nfrom chainlit.auth import create_jwt\nfrom chainlit.server import _authenticate_user, app\nfrom chainlit.user import User\nfrom fastapi import Request, Response\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\nos.environ[\"CHAINLIT_CUSTOM_AUTH\"] = \"true\"\n\n\n@app.get(\"/auth/custom\")\nasync def custom_auth(request: Request) -> Response:\n    user_id = str(uuid4())\n\n    user = User(identifier=user_id, metadata={\"role\": \"user\"})\n    response = await _authenticate_user(request, user)\n\n    return response\n\n\n@app.get(\"/auth/token\")\nasync def custom_token_auth() -> Response:\n    user_id = str(uuid4())\n\n    user = User(identifier=user_id, metadata={\"role\": \"admin\"})\n    response = create_jwt(user)\n\n    return response\n\n\ncatch_all_route = None\nfor route in app.routes:\n    if route.path == \"/{full_path:path}\":\n        catch_all_route = route\n\nif catch_all_route:\n    app.routes.remove(catch_all_route)\n    app.routes.append(catch_all_route)\n\n\n@cl.on_chat_start\nasync def on_chat_start():\n    user = cl.user_session.get(\"user\")\n    await cl.Message(f\"Hello {user.identifier}\").send()\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    await cl.Message(content=f\"Echo: {msg.content}\").send()\n"
  },
  {
    "path": "cypress/e2e/auth/spec.cy.ts",
    "content": "import { loadCopilotScript, mountCopilotWidget, openCopilot, submitMessage } from '../../support/testUtils';\n\nfunction login() {\n  return cy.request({\n    method: 'GET',\n    url: '/auth/custom',\n    followRedirect: false\n  })\n}\n\nfunction getToken() {\n  return cy.request({\n    method: 'GET',\n    url: '/auth/token',\n    followRedirect: false\n  })\n}\n\nfunction shouldShowGreetingMessage() {\n  it('should show greeting message', () => {\n    cy.get('.step').should('exist');\n    cy.get('.step').should('contain', 'Hello');\n  });\n}\n\nfunction shouldSendMessageAndRecieveAnswer() {\n  it('should send message and receive answer', () => {\n    cy.get('.step').should('contain', 'Hello');\n    \n    const testMessage = 'Test message from custom auth';\n    submitMessage(testMessage);\n\n    cy.get('.step').should('contain', 'Echo:');\n    cy.get('.step').should('contain', testMessage);\n  });\n\n}\n\ndescribe('Custom Auth', () => {\n  describe('when unauthenticated', () => {\n    beforeEach(() => {\n      cy.intercept('GET', '/user').as('user');\n    });\n\n    it('should attempt to and not have permission to access /user', () => {\n      cy.wait('@user').then((interception) => {\n        expect(interception.response.statusCode).to.equal(401);\n      });\n    });\n\n    it('should redirect to login dialog', () => {\n      cy.location('pathname').should('eq', '/login');\n    });\n  });\n\n  describe('authenticating via custom endpoint', () => {\n    beforeEach(() => {\n      login().then((response) => {\n        expect(response.status).to.equal(200);\n        // Verify cookie is set in response headers\n        expect(response.headers).to.have.property('set-cookie');\n        const cookies = Array.isArray(response.headers['set-cookie'])\n          ? response.headers['set-cookie']\n          : [response.headers['set-cookie']];\n        expect(cookies[0]).to.contain('access_token');\n      });\n    });\n\n    const shouldBeLoggedIn = () => {\n      it('should not be on /login', () => {\n        cy.location('pathname').should('not.contain', '/login');\n      });\n\n      shouldShowGreetingMessage();\n    };\n\n    shouldBeLoggedIn();\n\n    it('should request and have access to /user', () => {\n      cy.intercept('GET', '/user').as('user');\n      cy.wait('@user').then((interception) => {\n        expect(interception.response.statusCode).to.equal(200);\n      });\n    });\n\n    shouldSendMessageAndRecieveAnswer();\n\n    describe('after reloading', () => {\n      beforeEach(() => {\n        cy.reload();\n      });\n\n      shouldBeLoggedIn();\n    });\n  });\n});\n\ndescribe('Copilot Token', { includeShadowDom: true }, () => {\n  beforeEach(() => {\n    cy.location('pathname').should('eq', '/login');\n\n    loadCopilotScript();\n  });\n\n  describe('when unauthenticated', () => {\n    it('should throw error about missing authentication token', () => {\n      mountCopilotWidget();\n      openCopilot();\n      cy.get('#chainlit-copilot-chat').should('contain', 'No authentication token provided.');\n    });\n  });\n\n  describe('authenticating via custom endpoint', () => {\n    beforeEach(() => {\n      getToken().then((response) => {\n        expect(response.status).to.equal(200);\n\n        const accessToken = response.body\n        expect(accessToken).to.not.be.null;\n\n        mountCopilotWidget({ accessToken });\n        openCopilot();\n      });\n    })\n\n    shouldShowGreetingMessage();\n\n    shouldSendMessageAndRecieveAnswer();\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/blinking_cursor/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(\"Hello, this is a test message!\").send()\n\n\n@cl.step(type=\"tool\")\nasync def tool():\n    await cl.sleep(5)\n    return \"Response from the tool!\"\n\n\n@cl.on_message\nasync def on_message(message: cl.Message):\n    if message.content == \"tool\":\n        await tool()\n    else:\n        await cl.sleep(5)\n        await cl.Message(f\"Received message: {message.content}\").send()\n"
  },
  {
    "path": "cypress/e2e/blinking_cursor/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Blinking cursor', () => {\n  it('It should display until a step or message is sent', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.contains('.step', 'Hello, this is a test message!').should('be.visible');\n\n    submitMessage('tool');\n\n    cy.get('.step').should('have.length', 3);\n    cy.get('.step').last().should('have.attr', 'data-step-type', 'tool');\n    cy.get('.step').last().next('.animate-pulse').should('not.exist');\n\n    submitMessage('Jeeves');\n    cy.get('.step')\n      .last()\n      .next('.animate-pulse', { timeout: 500 })\n      .should('exist');\n    cy.get('.step')\n      .last()\n      .next('.animate-pulse', { timeout: 5500 })\n      .should('not.exist');\n    cy.get('.step').should('have.length', 5);\n    cy.get('.step').last().should('contain.text', 'Received message: Jeeves');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/chat_context/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def main():\n    await cl.Message(\n        content=f\"Chat context length: {len(cl.chat_context.get())}\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/chat_context/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Chat Context', () => {\n  it('should be able to current conversation chat history', () => {\n    submitMessage('Hello 1');\n\n    cy.get('.step').eq(1).should('contain', 'Chat context length: 1');\n\n    submitMessage('Hello 2');\n\n    cy.get('.step').eq(3).should('contain', 'Chat context length: 3');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/chat_prefill/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(\"Hello, this is a test message!\").send()\n"
  },
  {
    "path": "cypress/e2e/chat_prefill/spec.cy.ts",
    "content": "describe('Chat Prefill', () => {\n  it('should display a prefill message when the chat starts', () => {\n    cy.visit('/?prompt=Hello%20World');\n\n    cy.get('#chat-input', { timeout: 10000 })\n      .should('be.visible')\n      .and('have.value', 'Hello World');\n  });\n\n  it('should not prefill the chat when prompt is empty', () => {\n    cy.visit('/');\n\n    cy.get('#chat-input', { timeout: 10000 })\n      .should('be.visible')\n      .and('have.value', '');\n  });\n\n  it('should correctly prefill with special characters', () => {\n    const prompt = encodeURIComponent(\"Hi there! How's it going?\");\n    cy.visit(`/?prompt=${prompt}`);\n\n    cy.get('#chat-input', { timeout: 10000 })\n      .should('be.visible')\n      .and('have.value', \"Hi there! How's it going?\");\n  });\n\n  it('should focus the chat input when prefilled', () => {\n    cy.visit('/?prompt=FocusTest');\n\n    cy.get('#chat-input', { timeout: 10000 })\n      .should('be.visible')\n      .and('have.focus');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/chat_profiles/main.py",
    "content": "import os\nfrom typing import Optional\n\nimport chainlit as cl\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\nstarters = [\n    cl.Starter(\n        label=\"Say hi\",\n        message=\"Start a conversation with a greeting\",\n        icon=\"https://picsum.photos/300\",\n    ),\n    cl.Starter(\n        label=\"Ask for help\",\n        message=\"Ask for help with something\",\n        icon=\"https://picsum.photos/350\",\n    ),\n]\n\n\n@cl.set_chat_profiles\nasync def chat_profile(current_user: cl.User):\n    if current_user.metadata[\"role\"] != \"ADMIN\":\n        return None\n\n    return [\n        cl.ChatProfile(\n            name=\"GPT-3.5\",\n            icon=\"https://picsum.photos/250\",\n            markdown_description=\"The underlying LLM model is **GPT-3.5**, a *175B parameter model* trained on 410GB of text data.\",\n            starters=starters,\n        ),\n        cl.ChatProfile(\n            name=\"GPT-4\",\n            markdown_description=\"The underlying LLM model is **GPT-4**, a *1.5T parameter model* trained on 3.5TB of text data. [Learn more](https://example.com/gpt4)\",\n            icon=\"https://picsum.photos/250\",\n            starters=starters,\n        ),\n        cl.ChatProfile(\n            name=\"GPT-5\",\n            markdown_description=\"The underlying LLM model is **GPT-5**.\",\n            icon=\"https://picsum.photos/200\",\n            starters=starters,\n        ),\n    ]\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"admin\", \"admin\"):\n        return cl.User(identifier=\"admin\", metadata={\"role\": \"ADMIN\"})\n    else:\n        return None\n\n\n@cl.on_message\nasync def on_message():\n    user = cl.user_session.get(\"user\")\n    chat_profile = cl.user_session.get(\"chat_profile\")\n    await cl.Message(\n        content=f\"starting chat with {user.identifier} using the {chat_profile} chat profile\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/chat_profiles/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Chat profiles', () => {\n  it('should be able to select a chat profile', () => {\n    cy.visit('/');\n    cy.get(\"input[name='email']\").type('admin');\n    cy.get(\"input[name='password']\").type('admin');\n    cy.get(\"button[type='submit']\").click();\n    cy.get('#chat-input').should('exist');\n\n    cy.wait(1000);\n    cy.get('#starter-say-hi').should('exist').click();\n\n    cy.get('.step')\n      .should('have.length', 2)\n      .eq(0)\n      .should('contain', 'Start a conversation with a greeting');\n\n    cy.get('.step')\n      .eq(1)\n      .should(\n        'contain',\n        'starting chat with admin using the GPT-3.5 chat profile'\n      );\n\n    cy.get('#chat-profiles').click();\n    cy.get('[data-test=\"select-item:GPT-3.5\"]').should('exist');\n    cy.get('[data-test=\"select-item:GPT-4\"]').should('exist');\n    cy.get('[data-test=\"select-item:GPT-5\"]').should('exist');\n\n    // Change chat profile\n\n    cy.get('[data-test=\"select-item:GPT-4\"]').click();\n    cy.get('#confirm').click();\n\n    cy.wait(1000);\n\n    cy.get('#starter-ask-for-help').should('not.be.disabled').click();\n\n    cy.get('.step')\n      .should('have.length', 2)\n      .eq(0)\n      .should('contain', 'Ask for help with something');\n\n    cy.get('.step')\n      .eq(1)\n      .should(\n        'contain',\n        'starting chat with admin using the GPT-4 chat profile'\n      );\n\n    cy.get('#header').get('#new-chat-button').click({ force: true });\n    cy.get('#confirm').click();\n\n    cy.get('#starter-ask-for-help').should('exist');\n\n    cy.get('.step').should('have.length', 0);\n\n    submitMessage('hello');\n    cy.get('.step').should('have.length', 2).eq(0).should('contain', 'hello');\n    cy.get('#chat-profiles').click();\n    cy.get('[data-test=\"select-item:GPT-5\"]').click();\n    cy.get('#confirm').click();\n\n    cy.get('#starter-ask-for-help').should('exist');\n  });\n\n  it('should keep chat profile description visible when hovering over a link', () => {\n    cy.visit('/');\n    cy.get(\"input[name='email']\").type('admin');\n    cy.get(\"input[name='password']\").type('admin');\n    cy.get(\"button[type='submit']\").click();\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#chat-profiles').click();\n\n    // Force hover over GPT-4 profile to show description\n    cy.get('[data-test=\"select-item:GPT-4\"]').focus();\n\n    // Wait for the popover to appear and check its content\n    cy.get('#chat-profile-description').within(() => {\n      cy.contains('Learn more').should('be.visible');\n    });\n\n    // Check if the link is present in the description and has correct attributes\n    const linkSelector = '#chat-profile-description a:contains(\"Learn more\")';\n    cy.get(linkSelector)\n      .should('have.attr', 'href', 'https://example.com/gpt4')\n      .and('have.attr', 'target', '_blank');\n\n    // Move mouse to the link\n    cy.get(linkSelector).trigger('mouseover', { force: true });\n\n    // Verify that the description is still visible after\n    cy.get('#chat-profile-description').within(() => {\n      cy.contains('Learn more').should('be.visible');\n    });\n\n    // Verify that the link is still present and clickable\n    cy.get(linkSelector)\n      .should('exist')\n      .and('be.visible')\n      .and('not.have.css', 'pointer-events', 'none')\n      .and('not.have.attr', 'disabled');\n\n    // Ensure the chat profile selector is still open\n    cy.get('[data-test=\"select-item:GPT-4\"]').should('be.visible');\n\n    // Select GPT-4 profile\n    cy.get('[data-test=\"select-item:GPT-4\"]').click();\n\n    cy.wait(1000);\n\n    // Verify the profile has been changed\n    submitMessage('hello');\n    cy.get('.step')\n      .should('have.length', 2)\n      .last()\n      .should(\n        'contain',\n        'starting chat with admin using the GPT-4 chat profile'\n      );\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/chat_settings/main.py",
    "content": "import chainlit as cl\nfrom chainlit.input_widget import Select, Slider, Switch\n\n\n@cl.on_chat_start\nasync def start():\n    await cl.ChatSettings(\n        [\n            Select(\n                id=\"Model\",\n                label=\"OpenAI - Model\",\n                values=[\"gpt-3.5-turbo\", \"gpt-3.5-turbo-16k\", \"gpt-4\", \"gpt-4-32k\"],\n                initial_index=0,\n            ),\n            Switch(id=\"Streaming\", label=\"OpenAI - Stream Tokens\", initial=True),\n            Slider(\n                id=\"Temperature\",\n                label=\"OpenAI - Temperature\",\n                initial=1,\n                min=0,\n                max=2,\n                step=0.1,\n            ),\n            Slider(\n                id=\"SAI_Steps\",\n                label=\"Stability AI - Steps\",\n                initial=30,\n                min=0,\n                max=150,\n                step=1,\n                description=\"Amount of inference steps performed on image generation.\",\n            ),\n            Slider(\n                id=\"SAI_Cfg_Scale\",\n                label=\"Stability AI - Cfg_Scale\",\n                initial=7,\n                min=1,\n                max=35,\n                step=0.1,\n                description=\"Influences how strongly your generation is guided to match your prompt.\",\n            ),\n            Slider(\n                id=\"SAI_Width\",\n                label=\"Stability AI - Image Width\",\n                initial=512,\n                min=0,\n                max=2048,\n                step=64,\n                tooltip=\"Measured in pixels\",\n            ),\n            Slider(\n                id=\"SAI_Height\",\n                label=\"Stability AI - Image Height\",\n                initial=512,\n                min=0,\n                max=2048,\n                step=64,\n                tooltip=\"Measured in pixels\",\n            ),\n        ]\n    ).send()\n\n\n@cl.on_settings_update\nasync def setup_agent(settings):\n    await cl.Message(content=\"Settings updated!\").send()\n"
  },
  {
    "path": "cypress/e2e/chat_settings/spec.cy.ts",
    "content": "const openChatSettingsModal = () => {\n  cy.step('Open chat settings modal');\n\n  cy.get('#chat-settings-open-modal').should('exist').click();\n  cy.get('#chat-settings').should('exist').and('be.visible');\n};\n\ndescribe('Customize chat settings', () => {\n  it('should update inputs', () => {\n    openChatSettingsModal();\n\n    cy.step('Update inputs');\n\n    cy.get('#Model').click();\n    cy.get('[role=\"listbox\"]').should('be.visible');\n    cy.contains('gpt-4').click();\n    cy.get('#Model').should('contain.text', 'gpt-4');\n\n    cy.get('#Temperature')\n      .find('[role=\"slider\"]')\n      .focus()\n      .type('{leftarrow}'.repeat(6))\n      .should('have.attr', 'aria-valuenow', '0.4');\n\n    cy.get('#SAI_Steps')\n      .find('[role=\"slider\"]')\n      .focus()\n      .type('{rightarrow}'.repeat(6))\n      .should('have.attr', 'aria-valuenow', '36');\n\n    cy.get('#SAI_Cfg_Scale')\n      .find('[role=\"slider\"]')\n      .focus()\n      .type('{rightarrow}'.repeat(6))\n      .should('have.attr', 'aria-valuenow', '7.6');\n\n    cy.contains('Confirm').click();\n\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).should('contain', 'Settings updated!');\n\n    openChatSettingsModal();\n\n    cy.step('Check inputs are updated');\n\n    cy.get('#Model').should('contain.text', 'gpt-4');\n\n    cy.get('#Temperature')\n      .find('[role=\"slider\"]')\n      .should('have.attr', 'aria-valuenow', '0.4');\n\n    cy.get('#SAI_Steps')\n      .find('[role=\"slider\"]')\n      .should('have.attr', 'aria-valuenow', '36');\n\n    cy.get('#SAI_Cfg_Scale')\n      .find('[role=\"slider\"]')\n      .should('have.attr', 'aria-valuenow', '7.6');\n\n    cy.get('#SAI_Width')\n      .find('[role=\"slider\"]')\n      .should('have.attr', 'aria-valuenow', '512');\n\n    cy.get('#SAI_Height')\n      .find('[role=\"slider\"]')\n      .should('have.attr', 'aria-valuenow', '512');\n\n    cy.step('Check if modal is correctly closed');\n\n    cy.contains('Cancel').click();\n    cy.get('#chat-settings').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/command/main.py",
    "content": "import chainlit as cl\n\ncommands = [\n    {\"id\": \"Picture\", \"icon\": \"image\", \"description\": \"Use DALL-E\"},\n    {\"id\": \"Search\", \"icon\": \"globe\", \"description\": \"Find on the web\", \"button\": True},\n    {\n        \"id\": \"Canvas\",\n        \"icon\": \"pen-line\",\n        \"description\": \"Collaborate on writing and code\",\n    },\n    {\n        \"id\": \"Sticky\",\n        \"icon\": \"pin\",\n        \"description\": \"Persistent tool stays selected\",\n        \"persistent\": True,\n    },\n    {\n        \"id\": \"StickyButton\",\n        \"icon\": \"bookmark\",\n        \"description\": \"Persistent button tool\",\n        \"button\": True,\n        \"persistent\": True,\n    },\n]\n\n\n@cl.on_chat_start\nasync def start():\n    await cl.context.emitter.set_commands(commands)\n\n\n@cl.on_message\nasync def message(msg: cl.Message):\n    # Clear all commands after choosing Picture to test UI behavior with zero commands\n    # This simulates a scenario where certain commands might change the available tool set\n    if msg.command == \"Picture\":\n        await cl.context.emitter.set_commands([])\n\n    await cl.Message(content=f\"Command: {msg.command}\").send()\n"
  },
  {
    "path": "cypress/e2e/command/spec.cy.ts",
    "content": "import 'cypress-plugin-steps';\n\ndescribe('Command', () => {\n  // Taller viewport reduces header overlap in headless + absolute-positioned menus\n  beforeEach(() => {\n    cy.viewport(1280, 900);\n  });\n\n  it('should correctly display commands', () => {\n    cy.step('Type command shortcut and select Search');\n    cy.get(`#chat-input`).type('/sear');\n    cy.get('.command-item').should('have.length', 1);\n    cy.get('.command-item').eq(0).click();\n\n    cy.step('Send message with Search command');\n    cy.get(`#chat-input`).type('Hello{enter}');\n\n    cy.step('Verify Search command was applied');\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(0).find('.command-span').should('have.text', 'Search');\n\n    cy.get('#command-button').should('exist');\n\n    cy.get('.step')\n      .eq(1)\n      .invoke('text')\n      .then((text) => {\n        expect(text.trim()).to.equal('Command: Search');\n      });\n\n    cy.step('Select Picture command');\n    cy.get(`#chat-input`).type('/pic');\n    cy.get('.command-item').should('have.length', 1);\n    cy.get('.command-item').eq(0).click();\n\n    cy.step('Send message with Picture command');\n    cy.get(`#chat-input`).type('Hello{enter}');\n\n    cy.step('Verify Picture command clears all commands');\n    cy.get('.step').should('have.length', 4);\n    cy.get('.step').eq(2).find('.command-span').should('have.text', 'Picture');\n\n    // After selecting Picture, backend clears commands -> tools button disappears\n    cy.get('#command-button').should('not.exist');\n  });\n\n  it('should correctly display and interact with commands', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Verify initial command buttons are visible');\n    // Search and StickyButton are button commands\n    cy.get('#command-Search').should('exist').and('be.visible');\n    cy.get('#command-StickyButton').should('exist').and('be.visible');\n    cy.get('#command-Picture').should('not.exist');\n    cy.get('#command-Canvas').should('not.exist');\n    cy.get('#command-button').should('exist').and('be.visible');\n\n    cy.step('Open command menu and filter to Picture');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/');\n    cy.get('[data-index]').should('have.length.at.least', 3);\n\n    cy.get('#chat-input').type('pic');\n    cy.get('[data-index]').should('have.length', 1);\n    cy.get('[data-index=\"0\"]').should('contain', 'Picture');\n    cy.get('[data-index=\"0\"]').click();\n\n    cy.step('Submit message with Picture command');\n    cy.get('#chat-input').type('Generate an image{enter}');\n\n    cy.step('Verify command was sent and UI updated');\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Command: Picture');\n\n    // Backend cleared commands after Picture -> no tools button and no button pills\n    cy.get('#command-Search').should('not.exist');\n    cy.get('#command-StickyButton').should('not.exist');\n    cy.get('#command-button').should('not.exist');\n  });\n\n  it('should handle keyboard navigation in command menu', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Open command menu');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/');\n    cy.get('[data-index]').should('have.length.at.least', 3);\n\n    // Check first item is selected by default using data attribute\n    cy.get('[data-index=\"0\"]').should('satisfy', ($el) => {\n      // Component adds bg-accent class when selected, but we check for functional state\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n\n    cy.step('Navigate down with keyboard');\n    cy.get('#chat-input').type('{downArrow}');\n    cy.get('[data-index=\"1\"]').should('satisfy', ($el) => {\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n\n    cy.step('Navigate up with keyboard');\n    cy.get('#chat-input').type('{upArrow}');\n    cy.get('[data-index=\"0\"]').should('satisfy', ($el) => {\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n\n    cy.step('Select with Enter key');\n    cy.get('#chat-input').type('{enter}');\n    cy.get('[data-index]').should('not.exist');\n    cy.get('[id^=\"command-\"]').should('have.length.at.least', 1);\n  });\n\n  it('should handle Tools dropdown menu', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Open Tools dropdown');\n    cy.get('#command-button').should('exist').click();\n    cy.get('[data-popover-content]').should('be.visible');\n\n    // Non-button commands are listed: Picture, Canvas, Sticky\n    cy.get('[data-popover-content] [data-index]').should(\n      'have.length.at.least',\n      3\n    );\n\n    cy.step('Verify non-button commands are in dropdown');\n    cy.get('[data-popover-content]').should('contain', 'Picture');\n    cy.get('[data-popover-content]').should('contain', 'Canvas');\n    cy.get('[data-popover-content]').should('contain', 'Sticky');\n\n    cy.step('Select Canvas from dropdown');\n    cy.get('[data-popover-content] [data-index]').contains('Canvas').click();\n    cy.get('#command-Canvas').should('exist').and('be.visible');\n    cy.get('#command-button').should('exist');\n\n    cy.step('Send message with Canvas command');\n    cy.get('#chat-input').type('Collaborate on code{enter}');\n    cy.get('.step').should('contain', 'Command: Canvas');\n  });\n\n  it('should handle button command clicks', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Click Search button command');\n    cy.get('#command-Search').should('exist');\n    cy.get('#command-Search').click();\n\n    // Check that the button shows selected state (via text color class or aria attribute)\n    cy.get('#command-Search').should('satisfy', ($el) => {\n      // The component adds text-command class when selected\n      return (\n        $el.hasClass('text-command') ||\n        $el.find('.text-command').length > 0 ||\n        $el.attr('aria-pressed') === 'true' ||\n        $el.attr('data-selected') === 'true'\n      );\n    });\n\n    cy.step('Send message with Search command selected');\n    cy.get('#chat-input').type('Search for chainlit{enter}');\n    cy.get('.step').should('contain', 'Command: Search');\n    cy.get('#command-Search').should('exist');\n\n    cy.step('Deselect Search command');\n    cy.get('#command-Search').click();\n\n    cy.step('Send message without command');\n    cy.get('#chat-input').type('No command message{enter}');\n    cy.get('.step').last().should('contain', 'Command:');\n  });\n\n  it('should handle escape key to close command menu', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Open command menu');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/');\n    cy.get('[data-index]').should('exist').and('have.length.at.least', 3);\n\n    cy.step('Close menu with Escape key');\n    cy.get('#chat-input').type('{esc}');\n    cy.get('[data-index]').should('not.exist');\n    cy.get('#chat-input').should('have.value', '/');\n  });\n\n  it('should handle command selection via click', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Type partial command');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/can');\n\n    cy.step('Select Canvas from filtered results');\n    cy.get('[data-index]').should('have.length', 1);\n    cy.get('[data-index=\"0\"]').should('contain', 'Canvas');\n    cy.get('[data-index=\"0\"]').click();\n\n    cy.step('Verify Canvas command is selected');\n    cy.get('#command-Canvas').should('exist').and('be.visible');\n    cy.get('[data-index]').should('not.exist');\n    cy.get('#chat-input').invoke('val').should('not.contain', '/can');\n  });\n\n  it('should properly handle selected non-button command removal', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Select Canvas from Tools dropdown');\n    cy.get('#command-button').should('exist').click();\n    cy.get('[data-popover-content]').should('be.visible');\n    cy.get('[data-popover-content] [data-index]').contains('Canvas').click();\n\n    cy.step('Verify Canvas is selected');\n    cy.get('#command-Canvas').should('exist').and('be.visible');\n\n    cy.step('Deselect Canvas');\n    cy.get('#command-Canvas').click();\n    cy.get('#command-Canvas').should('not.exist');\n    cy.get('#command-button').should('exist');\n  });\n\n  it('should handle mouse hover in command menus', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Open command menu');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/');\n\n    // Make sure the menu itself is visible\n    cy.get('[data-index]').should('have.length.at.least', 3);\n\n    cy.step('Hover over second item');\n    // First item should be selected initially\n    cy.get('[data-index=\"0\"]').should('satisfy', ($el) => {\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n\n    cy.get('[data-index=\"1\"]')\n      .scrollIntoView()\n      .trigger('mousemove', { force: true });\n    cy.get('[data-index=\"1\"]').should('satisfy', ($el) => {\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n\n    cy.step('Verify selection persists after mouse leave');\n    cy.get('[data-index=\"1\"]').trigger('mouseleave', { force: true });\n    cy.wait(100);\n    cy.get('[data-index=\"1\"]').should('satisfy', ($el) => {\n      return $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true';\n    });\n  });\n\n  it('should filter commands correctly', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Filter for Picture command');\n    cy.get('#chat-input').click().clear();\n    cy.get('#chat-input').type('/pic');\n    cy.get('[data-index]').should('have.length', 1);\n    cy.get('[data-index=\"0\"]').should('contain', 'Picture');\n\n    cy.step('Filter for Canvas command');\n    cy.get('#chat-input').clear().type('/can');\n    cy.get('[data-index]').should('have.length', 1);\n    cy.get('[data-index=\"0\"]').should('contain', 'Canvas');\n\n    cy.step('Filter with non-matching text');\n    cy.get('#chat-input').clear().type('/xyz');\n    cy.get('[data-index]').should('not.exist');\n  });\n\n  it('should handle Tools dropdown keyboard navigation', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Open Tools dropdown');\n    cy.get('#command-button').should('exist').click();\n    cy.get('[data-popover-content]').should('be.visible');\n\n    // First item should be selected by default\n    cy.get('[data-popover-content] [data-index=\"0\"]').should(\n      'satisfy',\n      ($el) => {\n        return (\n          $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true'\n        );\n      }\n    );\n\n    cy.step('Navigate down in dropdown');\n    cy.get('[data-popover-content]').type('{downArrow}');\n    cy.get('[data-popover-content] [data-index=\"1\"]').should(\n      'satisfy',\n      ($el) => {\n        return (\n          $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true'\n        );\n      }\n    );\n\n    cy.step('Navigate up in dropdown');\n    cy.get('[data-popover-content]').type('{upArrow}');\n    cy.get('[data-popover-content] [data-index=\"0\"]').should(\n      'satisfy',\n      ($el) => {\n        return (\n          $el.hasClass('bg-accent') || $el.attr('aria-selected') === 'true'\n        );\n      }\n    );\n\n    cy.step('Select with Enter key');\n    cy.get('[data-popover-content]').type('{enter}');\n    cy.get('[data-popover-content]').should('not.exist');\n    cy.get(\n      '[id^=\"command-\"][id$=\"icture\"], [id^=\"command-\"][id$=\"anvas\"], [id^=\"command-\"][id$=\"ticky\"]'\n    ).should('exist');\n  });\n\n  it('should handle command persistence correctly (non-button)', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Select persistent Sticky command');\n    cy.get('#chat-input').click().clear().type('/sti');\n    cy.get('[data-index]').should('have.length.at.least', 1);\n    cy.get('[data-index]').contains('Sticky').click();\n\n    // Selected command should appear as a pill\n    cy.get('#command-Sticky').should('exist').and('be.visible');\n\n    cy.step('Send first message with persistent command');\n    cy.get('#chat-input').type('First sticky message{enter}');\n    cy.get('.step').last().should('contain', 'Command: Sticky');\n\n    cy.step('Send second message - command should persist');\n    cy.get('#chat-input').type('Second sticky message{enter}');\n    cy.get('.step').last().should('contain', 'Command: Sticky');\n\n    cy.step('Deselect persistent command');\n    cy.get('#command-Sticky').click();\n\n    cy.step('Send message without command');\n    cy.get('#chat-input').type('No command now{enter}');\n    cy.get('.step').last().should('contain', 'Command:');\n  });\n\n  it('should handle command persistence correctly (button)', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Select persistent StickyButton command');\n    cy.get('#command-StickyButton').should('exist').click();\n\n    cy.step('Send first message with persistent button');\n    cy.get('#chat-input').type('StickyButton #1{enter}');\n    cy.get('.step').last().should('contain', 'Command: StickyButton');\n\n    cy.step('Send second message - button should remain selected');\n    cy.get('#chat-input').type('StickyButton #2{enter}');\n    cy.get('.step').last().should('contain', 'Command: StickyButton');\n\n    cy.step('Deselect persistent button');\n    cy.get('#command-StickyButton').click();\n\n    cy.step('Send message without command');\n    cy.get('#chat-input').type('After deselect{enter}');\n    cy.get('.step').last().should('contain', 'Command:');\n  });\n\n  it('should show commands in correct places', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Verify button commands are visible as buttons');\n    // Buttons: Search & StickyButton (button=true) visible; non-button are not\n    cy.get('#command-Search').should('exist').and('be.visible');\n    cy.get('#command-StickyButton').should('exist').and('be.visible');\n    cy.get('#command-Picture').should('not.exist');\n    cy.get('#command-Canvas').should('not.exist');\n    cy.get('#command-Sticky').should('not.exist');\n\n    cy.step('Verify Tools menu contains non-button commands');\n    cy.get('#command-button').click();\n    cy.get('[data-popover-content]').within(() => {\n      cy.contains('Picture').should('exist');\n      cy.contains('Canvas').should('exist');\n      cy.contains('Sticky').should('exist');\n      cy.contains('Search').should('not.exist');\n      cy.contains('StickyButton').should('not.exist');\n    });\n\n    cy.get('body').click(0, 0);\n    cy.wait(200);\n\n    cy.step('Verify inline menu contains all commands');\n    cy.get('#chat-input').type('/');\n    cy.get('[data-index]').should('have.length', 5); // Total: Picture, Search, Canvas, Sticky, StickyButton\n\n    cy.get('[data-index]')\n      .parent()\n      .parent()\n      .within(() => {\n        cy.contains('Picture').should('exist');\n        cy.contains('Canvas').should('exist');\n        cy.contains('Search').should('exist');\n        cy.contains('Sticky').should('exist');\n        cy.contains('StickyButton').should('exist');\n      });\n  });\n\n  it('should test command clearing behavior with Picture command', () => {\n    cy.visit('/');\n    cy.get('#chat-input').should('exist');\n\n    cy.step('Verify initial commands are available');\n    cy.get('#command-Search').should('exist');\n    cy.get('#command-StickyButton').should('exist');\n    cy.get('#command-button').should('exist');\n\n    cy.step('Select and use Picture command');\n    cy.get('#chat-input').type('/pic');\n    cy.get('[data-index=\"0\"]').click();\n    cy.get('#chat-input').type('Generate a sunset{enter}');\n\n    cy.step('Verify all commands are cleared after Picture');\n    cy.get('#command-Search').should('not.exist');\n    cy.get('#command-StickyButton').should('not.exist');\n    cy.get('#command-button').should('not.exist');\n\n    cy.step('Verify slash command shows no commands');\n    cy.get('#chat-input').type('/');\n    cy.get('[data-index]').should('not.exist');\n\n    cy.step('Send regular message without commands');\n    cy.get('#chat-input').clear().type('Regular message{enter}');\n    cy.get('.step').last().should('contain', 'Command:');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/config_overrides/main.py",
    "content": "import os\nfrom typing import Optional\n\nimport chainlit as cl\nfrom chainlit.config import (\n    ChainlitConfigOverrides,\n    FeaturesSettings,\n    McpFeature,\n    UISettings,\n)\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\nstarters = [\n    cl.Starter(\n        label=\"Default Chat\",\n        message=\"Start a conversation with default settings\",\n        icon=\"https://picsum.photos/300\",\n    ),\n    cl.Starter(\n        label=\"MCP Test\",\n        message=\"Test MCP functionality\",\n        icon=\"https://picsum.photos/350\",\n    ),\n]\n\n\n@cl.set_chat_profiles\nasync def chat_profile(current_user: cl.User):\n    if current_user.metadata[\"role\"] != \"ADMIN\":\n        return None\n\n    return [\n        cl.ChatProfile(\n            name=\"Default Profile\",\n            icon=\"https://picsum.photos/250\",\n            markdown_description=\"Standard profile without MCP features. This profile uses **default settings** without any special configurations.\",\n            starters=starters,\n        ),\n        cl.ChatProfile(\n            name=\"MCP Enabled\",\n            markdown_description=\"Profile with **MCP features enabled**. This profile has *Model Context Protocol* support activated. [Learn more](https://example.com/mcp)\",\n            icon=\"https://picsum.photos/250\",\n            starters=starters,\n            config_overrides=ChainlitConfigOverrides(\n                ui=UISettings(name=\"MCP UI\"),\n                features=FeaturesSettings(\n                    mcp=McpFeature(\n                        enabled=True,\n                        stdio={\"enabled\": True},\n                        sse={\"enabled\": True},\n                        streamable_http={\"enabled\": True},\n                    )\n                ),\n            ),\n        ),\n        cl.ChatProfile(\n            name=\"MCP Disabled\",\n            markdown_description=\"Profile with **MCP explicitly disabled**. This ensures no MCP functionality is available.\",\n            icon=\"https://picsum.photos/200\",\n            starters=starters,\n            config_overrides=ChainlitConfigOverrides(\n                features=FeaturesSettings(mcp=McpFeature(enabled=False))\n            ),\n        ),\n    ]\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"admin\", \"admin\"):\n        return cl.User(identifier=\"admin\", metadata={\"role\": \"ADMIN\"})\n    else:\n        return None\n\n\n@cl.on_message\nasync def on_message():\n    user = cl.user_session.get(\"user\")\n    chat_profile = cl.user_session.get(\"chat_profile\")\n    await cl.Message(\n        content=f\"starting chat with {user.identifier} using the {chat_profile} chat profile\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/config_overrides/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Config overrides with chat profiles', () => {\n  it('should be able to select a chat profile and test MCP button visibility', () => {\n    cy.visit('/');\n    cy.get(\"input[name='email']\").type('admin');\n    cy.get(\"input[name='password']\").type('admin');\n    cy.get(\"button[type='submit']\").click();\n\n    // Verify we're on the main page after login\n    cy.location('pathname').should('eq', '/');\n    cy.get('#chat-input').should('exist');\n\n    // Wait for the interface to be ready\n    cy.get('#starter-default-chat').should('exist').click();\n\n    cy.get('.step')\n      .should('have.length', 2)\n      .eq(0)\n      .should('contain', 'Start a conversation with default settings');\n\n    cy.get('.step')\n      .eq(1)\n      .should(\n        'contain',\n        'starting chat with admin using the Default Profile chat profile'\n      );\n\n    // Test that MCP button (lucide plug) does not exist on Default Profile\n    cy.get('.lucide-plug').should('not.exist');\n\n    cy.get('#chat-profiles').click();\n    cy.get('[data-test=\"select-item:Default Profile\"]').should('exist');\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').should('exist');\n    cy.get('[data-test=\"select-item:MCP Disabled\"]').should('exist');\n\n    // Change to MCP Enabled chat profile\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').click();\n    cy.get('#confirm').click();\n\n    // Verify we're on a thread page after profile switch\n    cy.location('pathname').should('eq', '/');\n    cy.get('#starter-mcp-test').should('not.be.disabled').click();\n\n    cy.get('.step')\n      .should('have.length', 2)\n      .eq(0)\n      .should('contain', 'Test MCP functionality');\n\n    cy.get('.step')\n      .eq(1)\n      .should(\n        'contain',\n        'starting chat with admin using the MCP Enabled chat profile'\n      );\n\n    // Test that MCP button (lucide plug) exists on MCP Enabled profile\n    cy.get('.lucide-plug').should('exist').should('be.visible');\n\n    // Test switching to MCP Disabled profile\n    cy.get('#chat-profiles').click();\n    cy.get('[data-test=\"select-item:MCP Disabled\"]').click();\n    cy.get('#confirm').click();\n\n    // Test that MCP button (lucide plug) does not exist on MCP Disabled profile\n    cy.get('.lucide-plug').should('not.exist');\n\n    cy.get('#header').get('#new-chat-button').click({ force: true });\n    cy.get('#confirm').click();\n\n    cy.get('#starter-mcp-test').should('exist');\n\n    cy.get('.step').should('have.length', 0);\n\n    submitMessage('hello');\n    cy.get('.step').should('have.length', 2).eq(0).should('contain', 'hello');\n    cy.get('#chat-profiles').click();\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').click();\n    cy.get('#confirm').click();\n\n    // Verify MCP button appears again when switching back to MCP Enabled\n    cy.get('.lucide-plug').should('exist').should('be.visible');\n\n    cy.get('#starter-mcp-test').should('exist');\n  });\n\n  it('should keep chat profile description visible when hovering over a link', () => {\n    cy.visit('/');\n    cy.get(\"input[name='email']\").type('admin');\n    cy.get(\"input[name='password']\").type('admin');\n    cy.get(\"button[type='submit']\").click();\n\n    // Verify we're on the main page after login\n    cy.location('pathname').should('eq', '/');\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#chat-profiles').click();\n\n    // Force hover over MCP Enabled profile to show description\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').focus();\n\n    // Wait for the popover to appear and check its content\n    cy.get('#chat-profile-description').within(() => {\n      cy.contains('Learn more').should('be.visible');\n    });\n\n    // Check if the link is present in the description and has correct attributes\n    const linkSelector = '#chat-profile-description a:contains(\"Learn more\")';\n    cy.get(linkSelector)\n      .should('have.attr', 'href', 'https://example.com/mcp')\n      .and('have.attr', 'target', '_blank');\n\n    // Move mouse to the link\n    cy.get(linkSelector).trigger('mouseover', { force: true });\n\n    // Verify that the description is still visible after\n    cy.get('#chat-profile-description').within(() => {\n      cy.contains('Learn more').should('be.visible');\n    });\n\n    // Verify that the link is still present and clickable\n    cy.get(linkSelector)\n      .should('exist')\n      .and('be.visible')\n      .and('not.have.css', 'pointer-events', 'none')\n      .and('not.have.attr', 'disabled');\n\n    // Ensure the chat profile selector is still open\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').should('be.visible');\n\n    // Select MCP Enabled profile\n    cy.get('[data-test=\"select-item:MCP Enabled\"]').click();\n\n    // Verify we're on a thread page after profile selection\n    cy.location('pathname').should('eq', '/');\n    cy.get('.lucide-plug').should('exist');\n\n    // Verify the profile has been changed\n    submitMessage('hello');\n    cy.get('.step')\n      .should('have.length', 2)\n      .last()\n      .should(\n        'contain',\n        'starting chat with admin using the MCP Enabled chat profile'\n      );\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/context/main.py",
    "content": "import chainlit as cl\nfrom chainlit.context import context\nfrom chainlit.sync import make_async, run_sync\n\n\nasync def async_function_from_sync():\n    await cl.sleep(2)\n    return context.emitter\n\n\ndef sync_function():\n    emitter_from_make_async = context.emitter\n    emitter_from_async_from_sync = run_sync(async_function_from_sync())\n    return (emitter_from_make_async, emitter_from_async_from_sync)\n\n\nasync def async_function():\n    return await another_async_function()\n\n\nasync def another_async_function():\n    await cl.sleep(2)\n    return context.emitter\n\n\n@cl.on_chat_start\nasync def main():\n    emitter_from_async = await async_function()\n    if emitter_from_async:\n        await cl.Message(content=\"emitter from async found!\").send()\n    else:\n        await cl.ErrorMessage(content=\"emitter from async not found\").send()\n\n    emitter_from_make_async, emitter_from_async_from_sync = await make_async(\n        sync_function\n    )()\n\n    if emitter_from_make_async:\n        await cl.Message(content=\"emitter from make_async found!\").send()\n    else:\n        await cl.ErrorMessage(content=\"emitter from make_async not found\").send()\n\n    if emitter_from_async_from_sync:\n        await cl.Message(content=\"emitter from async_from_sync found!\").send()\n    else:\n        await cl.ErrorMessage(content=\"emitter from async_from_sync not found\").send()\n"
  },
  {
    "path": "cypress/e2e/context/spec.cy.ts",
    "content": "describe('Context should be reachable', () => {\n  it('should find the Emitter from async, make_async and async_from_sync contexts', () => {\n    cy.get('.step').should('have.length', 3);\n\n    cy.get('.step').eq(0).should('contain', 'emitter from async found!');\n\n    cy.get('.step').eq(1).should('contain', 'emitter from make_async found!');\n\n    cy.get('.step')\n      .eq(2)\n      .should('contain', 'emitter from async_from_sync found!');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/copilot/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def on_chat_start():\n    await cl.Message(content=\"Hi from copilot!\").send()\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    if cl.context.session.client_type == \"copilot\":\n        if msg.type == \"system_message\":\n            await cl.Message(content=f\"System message received: {msg.content}\").send()\n            return\n\n        fn = cl.CopilotFunction(name=\"test\", args={\"msg\": msg.content})\n        res = await fn.acall()\n        await cl.Message(content=res).send()\n"
  },
  {
    "path": "cypress/e2e/copilot/spec.cy.ts",
    "content": "import {\n  copilotShouldBeOpen,\n  clearCopilotThreadId,\n  getCopilotThreadId,\n  loadCopilotScript,\n  mountCopilotWidget,\n  openCopilot,\n  submitMessage,\n} from '../../support/testUtils';\n\ndescribe('Copilot', { includeShadowDom: true }, () => {\n  beforeEach(() => {\n    loadCopilotScript();\n  });\n\n  it('should be able to embed the copilot', () => {\n    cy.get('#chainlit-copilot').should('not.exist');\n    mountCopilotWidget();\n    cy.get('#chainlit-copilot').should('exist');\n    cy.window().then((win) => {\n      win.addEventListener('chainlit-call-fn', (e) => {\n        // @ts-expect-error is not a valid prop\n        win.sendChainlitMessage({\n          type: 'system_message',\n          output: 'Hello World!'\n        });\n        // @ts-expect-error is not a valid prop\n        const { name, args, callback } = e.detail;\n        if (name === 'test') {\n          callback('Function called with: ' + args.msg);\n        }\n      });\n    });\n\n    openCopilot();\n\n    cy.get('.step').should('have.length', 1);\n    cy.contains('.step', 'Hi from copilot!').should('be.visible');\n\n    cy.step('Start conversation');\n\n    submitMessage('Call func!');\n    cy.get('.step').should('have.length', 5);\n    cy.contains('.step', 'Function called with: Call func!').should(\n      'be.visible'\n    );\n    cy.contains('.step', 'System message received: Hello World!').should(\n      'be.visible'\n    );\n  });\n\n  it('should persist thread', () => {\n    mountCopilotWidget();\n    cy.step('Check persistance availability');\n\n    cy.window().should('have.property', 'getChainlitCopilotThreadId');\n    cy.window().should('have.property', 'clearChainlitCopilotThreadId');\n\n    getCopilotThreadId().then((threadId) => {\n      expect(threadId).to.equal(null);\n    });\n\n    openCopilot();\n\n    let firstThreadId: string;\n    getCopilotThreadId().then((threadId) => {\n      firstThreadId = threadId;\n      expect(firstThreadId).to.not.equal(null);\n    });\n\n    cy.step('Start conversation');\n\n    submitMessage('Hello Copilot!');\n\n    cy.get('.step').should('have.length', 2);\n    cy.contains('.step', 'Hi from copilot!').should('be.visible');\n    cy.contains('.step', 'Hello Copilot!').should('be.visible');\n\n    cy.step('Start new thread programmatically');\n\n    clearCopilotThreadId();\n\n    cy.wait(1000); // Wait for the thread ID to be cleared\n\n    getCopilotThreadId().then((threadId) => {\n      expect(threadId).to.not.equal(null);\n      expect(threadId).to.not.equal(firstThreadId);\n    });\n\n    cy.get('.step').should('have.length', 1);\n\n    cy.step('Start conversation');\n\n    submitMessage('Hello Copilot from a new thread!');\n    cy.get('.step').should('have.length', 2);\n    cy.contains('.step', 'Hi from copilot!').should('be.visible');\n    cy.contains('.step', 'Hello Copilot from a new thread!').should(\n      'be.visible'\n    );\n\n    cy.step('Start new thread programmatially with predefined ID');\n\n    const newThreadId = crypto.randomUUID();\n    clearCopilotThreadId(newThreadId);\n\n    cy.wait(1000); // Wait for the thread ID to be cleared\n\n    getCopilotThreadId().then((threadId) => {\n      expect(threadId).to.equal(newThreadId);\n    });\n\n    cy.get('.step').should('have.length', 1);\n    cy.contains('.step', 'Hi from copilot!').should('be.visible');\n\n    cy.step('Start new thread from UI');\n\n    cy.get('#new-chat-button').click();\n    cy.get('#new-chat-dialog').should('exist');\n    cy.get('#new-chat-dialog').within(() => {\n      cy.get('#confirm').click();\n    });\n\n    cy.wait(1000); // Wait for the new chat to be created\n\n    getCopilotThreadId().then((threadId) => {\n      expect(threadId).to.not.equal(null);\n      expect(threadId).to.not.equal(newThreadId);\n    });\n  });\n\n  describe('Language from config', () => {\n    const testData = [\n      {\n        language: 'en-US',\n        placeholder: 'Type your message here...'\n      },\n      {\n        language: 'fr-FR',\n        placeholder: 'Tapez votre message ici...'\n      }\n    ];\n\n    testData.forEach(({ language, placeholder }) => {\n      it(`should support ${language}`, () => {\n        mountCopilotWidget({\n          language\n        });\n        openCopilot();\n\n        cy.step('Check input placeholder');\n        cy.get('#chat-input').should(\n          'have.attr',\n          'placeholder',\n          placeholder\n        );\n      });\n    });\n  });\n\n  it('should be opened if config.opened is true', () => {\n    mountCopilotWidget({\n      opened: true\n    });\n\n    copilotShouldBeOpen();\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/custom_build/.gitignore",
    "content": "!.chainlit/config.toml"
  },
  {
    "path": "cypress/e2e/custom_build/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(content=\"Hello!\").send()\n"
  },
  {
    "path": "cypress/e2e/custom_build/public/.gitignore",
    "content": "!build\n!dist"
  },
  {
    "path": "cypress/e2e/custom_build/public/build/assets/.PLACEHOLDER",
    "content": ""
  },
  {
    "path": "cypress/e2e/custom_build/public/build/index.html",
    "content": "<html>\n    <head>\n        <title>Custom Build</title>\n    </head>\n    <body>\n        <p>This is a test page for custom build configuration.</p>\n    </body>\n</html>"
  },
  {
    "path": "cypress/e2e/custom_build/spec.cy.ts",
    "content": "describe('Custom Build', () => {\n  it('should correctly serve the custom build page', () => {\n    cy.get('body').contains(\n      'This is a test page for custom build configuration.'\n    );\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/custom_data_layer/sql_alchemy.py",
    "content": "import os\nfrom typing import Optional\n\nimport chainlit as cl\nfrom chainlit.data.sql_alchemy import SQLAlchemyDataLayer\nfrom chainlit.data.storage_clients.azure import AzureStorageClient\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\nstorage_client = AzureStorageClient(\n    account_url=\"<your_account_url>\", container=\"<your_container>\"\n)\n\n\n@cl.data_layer\ndef data_layer():\n    return SQLAlchemyDataLayer(\n        conninfo=\"<your conninfo>\", storage_provider=storage_client\n    )\n\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(\"Hello, send me a message!\").send()\n\n\n@cl.on_message\nasync def handle_message():\n    await cl.sleep(2)\n    await cl.Message(\"Ok!\").send()\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"admin\", \"admin\"):\n        return cl.User(identifier=\"admin\")\n    else:\n        return None\n"
  },
  {
    "path": "cypress/e2e/custom_element/main.py",
    "content": "import chainlit as cl\n\n\n@cl.action_callback(\"test\")\nasync def on_test_action():\n    await cl.sleep(1)\n    await cl.Message(content=\"Executed test action!\").send()\n\n\n@cl.on_chat_start\nasync def on_start():\n    custom_element = cl.CustomElement(name=\"Counter\", props={\"count\": 1})\n    await cl.Message(\n        content=\"This message has a custom element!\", elements=[custom_element]\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/custom_element/public/elements/Counter.jsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { X } from 'lucide-react';\n\nexport default function Counter() {\n    return (\n        <div id=\"custom-counter\" className=\"mt-4 flex flex-col gap-2\">\n                <div>Count: {props.count}</div>\n                {props.loading ? \"Loading...\" : null}\n                <Button id=\"increment\" onClick={() => updateElement(Object.assign(props, {count: props.count + 1}))}> Increment</Button>\n                <Button id=\"action\" onClick={async() => {\n                await updateElement(Object.assign(props, {loading: true}))\n                await callAction({name: \"test\", payload: {}})\n                await updateElement(Object.assign(props, {loading: false}))\n                }}>Run test action</Button>\n                <Button id=\"remove\" onClick={deleteElement}><X/> Remove</Button>\n        </div>\n    );\n}\n\n"
  },
  {
    "path": "cypress/e2e/custom_element/spec.cy.ts",
    "content": "describe('Custom Element', () => {\n  function getCustomElement() {\n    return cy.get('.step').eq(0).find('.inline-custom').first();\n  }\n\n  it('should be able to render an interactive custom element', () => {\n    cy.get('.step').should('have.length', 1);\n\n    cy.get('.step').eq(0).find('.inline-custom').should('have.length', 1);\n\n    getCustomElement().should('contain', 'Count: 1');\n\n    getCustomElement().find('#increment').click();\n    getCustomElement().should('contain', 'Count: 2');\n\n    getCustomElement().find('#increment').click();\n    getCustomElement().should('contain', 'Count: 3');\n\n    getCustomElement().find('#action').click();\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Executed test action!');\n\n    getCustomElement().find('#remove').click();\n    cy.get('.step').eq(0).find('.inline-custom').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/custom_element_auth/main.py",
    "content": "import os\nfrom typing import Optional\nimport chainlit as cl\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"admin\", \"admin\"):\n        return cl.User(identifier=\"admin\")\n    else:\n        return None\n\n\n@cl.on_chat_start\nasync def on_start():\n    await cl.Message(content=\"Hello world!\").send()\n"
  },
  {
    "path": "cypress/e2e/custom_element_auth/spec.cy.ts",
    "content": "import { setupWebSocketListener } from '../../support/testUtils';\n\ndescribe('Custom Element Auth', () => {\n  it('should not allow arbitrary file read', () => {\n    let chainlitKey: string | null = null;\n    let sessionId: string | null = null;\n\n    setupWebSocketListener('element', (data) => {\n      chainlitKey = data.chainlitKey;\n    });\n\n    cy.intercept('POST', '/login').as('login');\n    cy.intercept('POST', '/set-session-cookie').as('setSession');\n\n    cy.get('input[name=\"email\"]').type('admin');\n    cy.get('input[name=\"password\"]').type('admin');\n    cy.get('button[type=\"submit\"]').click();\n\n    cy.get('.step').should('have.length', 1);\n\n    cy.wait('@setSession').then((interception) => {\n      sessionId = interception.request.body.session_id;\n    });\n\n    cy.wrap(null).should(() => {\n      expect(sessionId).to.not.equal(null);\n    });\n\n    cy.then(() => {\n      cy.request({\n        method: 'PUT',\n        url: '/project/element',\n        body: {\n          element: {\n            type: 'custom',\n            id: 'test',\n            name: 'test',\n            display: 'inline',\n            path: 'cypress/e2e/custom_element_auth/test.txt'\n          },\n          sessionId: sessionId\n        }\n      });\n    });\n\n    cy.wrap(null).should(() => {\n      expect(chainlitKey).to.not.equal(null);\n    });\n\n    cy.then(() => {\n      cy.request({\n        method: 'GET',\n        url: `/project/file/${chainlitKey}`,\n        qs: { session_id: sessionId }\n      }).then((response) => {\n        expect(response.body).to.not.equal('Test');\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/custom_element_auth/test.txt",
    "content": "Test"
  },
  {
    "path": "cypress/e2e/custom_element_command/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def on_start():\n    custom_element = cl.CustomElement(name=\"Commander\")\n    await cl.Message(\n        content=\"This message has a custom element!\", elements=[custom_element]\n    ).send()\n\n\n@cl.on_message\nasync def on_message(message: cl.Message):\n    if message.command:\n        await cl.Message(content=f\"Received command: {message.command}\").send()\n"
  },
  {
    "path": "cypress/e2e/custom_element_command/public/elements/Commander.jsx",
    "content": "import { Button } from '@/components/ui/button';\n\nexport default function Commander() {\n  return (\n    <div id=\"custom-commander\" className=\"mt-4 flex flex-col gap-2\">\n      <Button\n        id=\"send\"\n        onClick={() =>\n          sendUserMessage('Hello from custom element', 'my_command')\n        }\n      >\n        {' '}\n        Send with command\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "cypress/e2e/custom_element_command/spec.cy.ts",
    "content": "describe('Custom Element Command', () => {\n  it('should be able to send a command from a custom element', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).find('.inline-custom').should('have.length', 1);\n\n    cy.get('#send').click();\n\n    cy.get('.step').should('have.length', 3);\n    cy.get('.step').eq(1).should('contain', 'Hello from custom element');\n    cy.get('.step').eq(2).should('contain', 'Received command: my_command');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/custom_theme/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(content=\"Hello!\").send()\n"
  },
  {
    "path": "cypress/e2e/custom_theme/public/theme.json",
    "content": "{\n    \"custom_fonts\": [\"https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto\"],\n    \"variables\": {\n        \"light\": {\n            \"--font-sans\": \"'Poppins', sans-serif\",\n            \"--background\": \"0 100% 50%\"\n        },\n        \"dark\": {\n            \"--font-sans\": \"'Roboto', sans-serif\",\n            \"--background\": \"100 100% 50%\"\n        }\n    }\n}\n\n"
  },
  {
    "path": "cypress/e2e/custom_theme/spec.cy.ts",
    "content": "describe('Custom Theme', () => {\n  it('should have the roboto font family and green bg in dark theme', () => {\n    // The hsl value is converted to rgb\n    cy.get('body').should('have.css', 'background-color', 'rgb(85, 255, 0)');\n    cy.get('body').should('have.css', 'font-family', 'Roboto, sans-serif');\n  });\n\n  it('should have the poppins font family and red bg in light theme', () => {\n    cy.visit('/');\n    cy.get('#theme-toggle').click();\n    cy.contains('Light').click();\n    // The hsl value is converted to rgb\n    cy.get('body').should('have.css', 'background-color', 'rgb(255, 0, 0)');\n    cy.get('body').should('have.css', 'font-family', 'Poppins, sans-serif');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/data_layer/.gitignore",
    "content": "thread_history.pickle"
  },
  {
    "path": "cypress/e2e/data_layer/main.py",
    "content": "import os\nimport os.path\nimport pickle\nfrom typing import Dict, List, Optional\n\nimport chainlit as cl\nimport chainlit.data as cl_data\nfrom chainlit.data.utils import queue_until_user_message\nfrom chainlit.element import Element, ElementDict\nfrom chainlit.socket import persist_user_session\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    Feedback,\n    PageInfo,\n    PaginatedResponse,\n    Pagination,\n    ThreadDict,\n    ThreadFilter,\n)\nfrom chainlit.utils import utc_now\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\nnow = utc_now()\n\nthread_history = [\n    {\n        \"id\": \"test1\",\n        \"name\": \"thread 1\",\n        \"createdAt\": now,\n        \"userId\": \"user1_id\",\n        \"userIdentifier\": \"user1\",\n        \"steps\": [\n            {\n                \"id\": \"test1\",\n                \"name\": \"test\",\n                \"createdAt\": now,\n                \"type\": \"user_message\",\n                \"output\": \"Message 1\",\n            },\n            {\n                \"id\": \"test2\",\n                \"name\": \"test\",\n                \"createdAt\": now,\n                \"type\": \"assistant_message\",\n                \"output\": \"Message 2\",\n            },\n        ],\n    },\n    {\n        \"id\": \"test2\",\n        \"createdAt\": now,\n        \"userId\": \"user1_id\",\n        \"userIdentifier\": \"user1\",\n        \"name\": \"thread 2\",\n        \"steps\": [\n            {\n                \"id\": \"test3\",\n                \"createdAt\": now,\n                \"name\": \"test\",\n                \"type\": \"user_message\",\n                \"output\": \"Message 3\",\n            },\n            {\n                \"id\": \"test4\",\n                \"createdAt\": now,\n                \"name\": \"test\",\n                \"type\": \"assistant_message\",\n                \"output\": \"Message 4\",\n            },\n        ],\n    },\n]  # type: List[ThreadDict]\ndeleted_thread_ids = []  # type: List[str]\nELEMENTS_STORAGE = []\n\nTHREAD_HISTORY_PICKLE_PATH = os.path.join(\n    os.path.dirname(__file__), \"thread_history.pickle\"\n)\nif THREAD_HISTORY_PICKLE_PATH and os.path.exists(THREAD_HISTORY_PICKLE_PATH):\n    with open(THREAD_HISTORY_PICKLE_PATH, \"rb\") as f:\n        thread_history = pickle.load(f)\n\n\nasync def save_thread_history():\n    # Force saving of thread history for reload when server restarts\n    await persist_user_session(\n        cl.context.session.thread_id, cl.context.session.to_persistable()\n    )\n\n    with open(THREAD_HISTORY_PICKLE_PATH, \"wb\") as out_file:\n        pickle.dump(thread_history, out_file)\n\n\nclass TestDataLayer(cl_data.BaseDataLayer):\n    async def get_user(self, identifier: str):\n        if identifier == \"user1\":\n            return cl.PersistedUser(id=\"user1_id\", createdAt=now, identifier=identifier)\n        elif identifier == \"user2\":\n            return cl.PersistedUser(id=\"user2_id\", createdAt=now, identifier=identifier)\n        return None\n\n    async def create_user(self, user: cl.User):\n        if user.identifier == \"user1\":\n            return cl.PersistedUser(\n                id=\"user1_id\", createdAt=now, identifier=user.identifier\n            )\n        elif user.identifier == \"user2\":\n            return cl.PersistedUser(\n                id=\"user2_id\", createdAt=now, identifier=user.identifier\n            )\n        return None\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        thread = next((t for t in thread_history if t[\"id\"] == thread_id), None)\n        if thread:\n            if name:\n                thread[\"name\"] = name\n            if metadata:\n                thread[\"metadata\"] = metadata\n            if tags:\n                thread[\"tags\"] = tags\n        else:\n            thread_history.append(\n                {\n                    \"id\": thread_id,\n                    \"name\": name,\n                    \"metadata\": metadata,\n                    \"tags\": tags,\n                    \"createdAt\": utc_now(),\n                    \"userId\": user_id,\n                    \"userIdentifier\": \"user1\"\n                    if user_id == \"user1_id\"\n                    else \"user2\"\n                    if user_id == \"user2_id\"\n                    else \"unknown\",\n                    \"steps\": [],\n                }\n            )\n\n    @cl_data.queue_until_user_message()\n    async def create_step(self, step_dict: StepDict):\n        cl.user_session.set(\n            \"create_step_counter\", cl.user_session.get(\"create_step_counter\") + 1\n        )\n\n        thread = next(\n            (t for t in thread_history if t[\"id\"] == step_dict.get(\"threadId\")), None\n        )\n        if thread:\n            thread[\"steps\"].append(step_dict)\n\n    async def get_thread_author(self, thread_id: str):\n        thread = await self.get_thread(thread_id)\n        return thread[\"userIdentifier\"] if thread else None\n\n    async def list_threads(\n        self, pagination: Pagination, filters: ThreadFilter\n    ) -> PaginatedResponse[ThreadDict]:\n        return PaginatedResponse(\n            data=[t for t in thread_history if t[\"id\"] not in deleted_thread_ids],\n            pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None),\n        )\n\n    async def get_thread(self, thread_id: str):\n        thread = next((t for t in thread_history if t[\"id\"] == thread_id), None)\n        if not thread:\n            return None\n        thread[\"steps\"] = sorted(thread[\"steps\"], key=lambda x: x[\"createdAt\"])\n        return thread\n\n    async def delete_thread(self, thread_id: str):\n        deleted_thread_ids.append(thread_id)\n\n    async def delete_feedback(\n        self,\n        feedback_id: str,\n    ) -> bool:\n        return True\n\n    async def upsert_feedback(\n        self,\n        feedback: Feedback,\n    ) -> str:\n        return \"\"\n\n    @queue_until_user_message()\n    async def create_element(self, element: \"Element\"):\n        if element.url == \"http://example.org/test.txt\":\n            element.url = \"http://example.com/test.txt\"\n\n        ELEMENTS_STORAGE.append(element.to_dict())\n\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        return next((e for e in ELEMENTS_STORAGE if e[\"id\"] == element_id), None)\n\n    @queue_until_user_message()\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        pass\n\n    @queue_until_user_message()\n    async def update_step(self, step_dict: \"StepDict\"):\n        pass\n\n    @queue_until_user_message()\n    async def delete_step(self, step_id: str):\n        pass\n\n    async def get_favorite_steps(self, user_id: str) -> List[\"StepDict\"]:\n        return []\n\n    async def build_debug_url(self) -> str:\n        return \"\"\n\n    async def close(self) -> None:\n        pass\n\n\n@cl.data_layer\ndef data_layer():\n    return TestDataLayer()\n\n\nasync def send_count():\n    create_step_counter = cl.user_session.get(\"create_step_counter\")\n    await cl.Message(f\"Create step counter: {create_step_counter}\").send()\n\n\n@cl.on_chat_start\nasync def main():\n    # Add step counter to session so that it is saved in thread metadata\n    cl.user_session.set(\"create_step_counter\", 0)\n    await cl.Message(\"Hello, send me a message!\").send()\n    await send_count()\n\n\n@cl.on_message\nasync def handle_message():\n    # Wait for queue to be flushed\n    await cl.sleep(2)\n    await send_count()\n    async with cl.Step(type=\"tool\", name=\"thinking\") as step:\n        step.output = \"Thinking...\"\n    await cl.Message(\"Ok!\").send()\n    await send_count()\n\n    await save_thread_history()\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"user1\", \"user1\"):\n        return cl.User(identifier=\"user1\")\n    elif (username, password) == (\"user2\", \"user2\"):\n        return cl.User(identifier=\"user2\")\n    else:\n        return None\n\n\n@cl.on_chat_resume\nasync def on_chat_resume(thread: ThreadDict):\n    await cl.Message(f\"Welcome back to {thread['name']}\").send()\n    if \"metadata\" in thread:\n        await cl.Message(thread[\"metadata\"], author=\"metadata\", language=\"json\").send()\n    if \"tags\" in thread:\n        await cl.Message(thread[\"tags\"], author=\"tags\", language=\"json\").send()\n"
  },
  {
    "path": "cypress/e2e/data_layer/spec.cy.ts",
    "content": "import { platform } from 'os';\nimport { sep } from 'path';\n\nimport { setupWebSocketListener, submitMessage } from '../../support/testUtils';\n\n// Constants\nconst SELECTORS = {\n  EMAIL_INPUT: '#email',\n  PASSWORD_INPUT: '#password',\n  AI_MESSAGE: \"[data-step-type='assistant_message']\",\n  CHAT_SUBMIT: '#chat-submit',\n  POSITIVE_FEEDBACK: '.positive-feedback-off',\n  NEGATIVE_FEEDBACK: '.negative-feedback-off',\n  SUBMIT_FEEDBACK: '#submit-feedback',\n  POSITIVE_FEEDBACK_ACTIVE: '.positive-feedback-on',\n  STEP: '.step',\n  THREAD_HISTORY: '#thread-history',\n  THREAD_TEST1: '#thread-test1',\n  THREAD_TEST2: '#thread-test2',\n  THREAD_OPTIONS: '#thread-options',\n  DELETE_THREAD: '#delete-thread',\n  CONFIRM_BUTTON: \"[role='alertdialog'] button.bg-primary\",\n  NEW_CHAT_BUTTON: '#new-chat-button',\n  CONFIRM_NEW: '#confirm',\n  LOADER: '.lucide-loader'\n} as const;\n\n// Utility functions\n\nconst login = (username: string = 'user1', password: string = 'user1') => {\n  cy.step('Verify login');\n\n  cy.location('pathname').should('eq', '/login');\n\n  cy.get(SELECTORS.EMAIL_INPUT).should('be.visible').type(username);\n  cy.get(SELECTORS.PASSWORD_INPUT)\n    .should('be.visible')\n    .type(`${password}{enter}`);\n};\n\nconst startConversation = () => {\n  cy.step('Start conversation');\n\n  cy.location('pathname').should('eq', '/');\n\n  cy.get(SELECTORS.AI_MESSAGE)\n    .should('exist')\n    .and('be.visible')\n    .and('have.length', 2);\n\n  submitMessage('Hello');\n\n  cy.location('pathname').should('match', /^\\/thread\\//);\n\n  cy.get(SELECTORS.AI_MESSAGE).should('exist').and('be.visible');\n};\n\nconst verifyFeedback = () => {\n  cy.step('Verify feedback');\n\n  cy.get(SELECTORS.NEGATIVE_FEEDBACK).should('have.length', 1);\n  cy.get(SELECTORS.POSITIVE_FEEDBACK).should('have.length', 1).first().click();\n  cy.get(SELECTORS.SUBMIT_FEEDBACK).should('be.visible').click();\n  cy.get(SELECTORS.POSITIVE_FEEDBACK_ACTIVE).should('have.length', 1);\n};\n\nconst verifyThreadQueue = () => {\n  cy.step('Verify thread queue');\n\n  cy.get(SELECTORS.STEP).eq(1).should('contain.text', 'Create step counter: 0');\n  cy.get(SELECTORS.STEP).eq(3).should('contain.text', 'Create step counter: 5');\n  cy.get(SELECTORS.STEP).eq(6).should('contain.text', 'Create step counter: 8');\n};\n\nconst verifyThreadList = () => {\n  cy.step('Verify thread list');\n\n  cy.get(SELECTORS.THREAD_TEST1).should('contain.text', 'thread 1');\n  cy.get(SELECTORS.THREAD_TEST2).should('contain.text', 'thread 2');\n\n  cy.step('Verify thread page');\n\n  cy.get(SELECTORS.THREAD_TEST1).click();\n  cy.get(SELECTORS.STEP).should('have.length', 2);\n  cy.get(SELECTORS.STEP).eq(0).should('contain.text', 'Message 1');\n  cy.get(SELECTORS.STEP).eq(1).should('contain.text', 'Message 2');\n\n  cy.step('Verify thread deletion');\n\n  cy.get(SELECTORS.THREAD_TEST1).find(SELECTORS.THREAD_OPTIONS).click();\n  cy.get(SELECTORS.DELETE_THREAD).should('be.visible').click();\n  cy.get(SELECTORS.CONFIRM_BUTTON).should('be.visible').click();\n  cy.get(SELECTORS.THREAD_TEST1).should('not.exist');\n  cy.get('body').type('{esc}');\n};\n\nconst verifyThreadResume = () => {\n  cy.step('Verify thread resume');\n\n  cy.get('body').should('have.css', 'pointer-events', 'auto');\n\n  cy.get(SELECTORS.THREAD_TEST2).click();\n  cy.get(SELECTORS.LOADER).should('not.be.visible');\n\n  cy.get(SELECTORS.THREAD_HISTORY).contains('Hello').click();\n  cy.get(SELECTORS.LOADER).should('not.be.visible');\n\n  cy.get(SELECTORS.STEP).should('have.length', 10);\n  cy.get(SELECTORS.STEP).eq(0).should('contain.text', 'Hello');\n  cy.get(SELECTORS.STEP).eq(7).should('contain.text', 'Welcome back to Hello');\n  cy.get(SELECTORS.STEP).eq(8).should('contain.text', 'chat_profile');\n};\n\nconst verifyContinueThread = () => {\n  cy.step('Verify thread continuation');\n\n  cy.get(SELECTORS.STEP).eq(7).should('contain.text', 'Welcome back to Hello');\n  submitMessage('Hello after restart');\n\n  cy.get(SELECTORS.STEP)\n    .eq(11)\n    .should('contain.text', 'Create step counter: 14');\n  cy.get(SELECTORS.STEP)\n    .eq(14)\n    .should('contain.text', 'Create step counter: 17');\n};\n\nconst startNewThread = () => {\n  cy.step('Start new thread');\n\n  cy.get(SELECTORS.NEW_CHAT_BUTTON).click();\n  cy.get(SELECTORS.CONFIRM_NEW).click();\n};\n\nconst cleanupThreadHistory = () => {\n  const pathItems = Cypress.spec.absolute.split(sep);\n  pathItems[pathItems.length - 1] = 'thread_history.pickle';\n  const threadHistoryFile = pathItems.join(sep);\n\n  // Clean up thread history file\n  const command =\n    platform() === 'win32'\n      ? `del /f \"${threadHistoryFile}\"`\n      : `rm -f \"${threadHistoryFile}\"`;\n  cy.exec(command, { failOnNonZeroExit: false });\n};\n\ndescribe.skip('Data Layer', () => {\n  describe('Data Features with Persistence', () => {\n    before(cleanupThreadHistory);\n    afterEach(cleanupThreadHistory);\n\n    it('Verifies login, feedback, thread queue, thread list, and thread resume functionality', () => {\n      login();\n      startConversation();\n\n      verifyFeedback();\n      verifyThreadQueue();\n\n      verifyThreadList();\n      verifyThreadResume();\n    });\n\n    it('Verifies thread continuation after server restart and new thread creation', () => {\n      cy.task('restartChainlit', Cypress.spec).then(() => {\n        cy.section('Before server restart');\n\n        cy.visit('/');\n\n        login();\n        startConversation();\n\n        verifyFeedback();\n        verifyThreadQueue();\n      });\n\n      cy.task('restartChainlit', Cypress.spec).then(() => {\n        cy.section('After server restart');\n\n        verifyContinueThread();\n\n        startNewThread();\n        startConversation();\n\n        verifyFeedback();\n        verifyThreadQueue();\n      });\n    });\n  });\n});\n\ndescribe('Access Control', () => {\n  before(cleanupThreadHistory);\n  afterEach(cleanupThreadHistory);\n\n  it(\"should not allow steal user's thread\", () => {\n    login('user1', 'user1');\n    startConversation();\n\n    let stolenThreadId = '';\n    cy.location('pathname')\n      .should('match', /^\\/thread\\//)\n      .then((pathname) => {\n        const parts = pathname.split('/');\n        stolenThreadId = parts[2];\n        expect(stolenThreadId).to.match(/^[a-zA-Z0-9_-]+$/);\n      });\n\n    cy.clearCookies();\n    cy.clearLocalStorage();\n\n    login('user2', 'user2');\n\n    cy.intercept(\n      {\n        method: 'POST',\n        url: /\\/ws\\/socket\\.io\\/.*transport=polling/\n      },\n      (request) => {\n        if (\n          typeof request.body === 'string' &&\n          request.body.includes('\"threadId\"')\n        ) {\n          request.body = request.body.replace(\n            /(\"threadId\":\\s*\")[^\"]*(\")/,\n            `$1${stolenThreadId}$2`\n          );\n          expect(request.url).to.include('/ws/socket.io/');\n          expect(request.body).to.include(`\"threadId\":\"${stolenThreadId}\"`);\n        }\n      }\n    );\n    startNewThread();\n\n    cy.get(SELECTORS.STEP).should('have.length', 0);\n  });\n\n  it('should not allow request forgery', () => {\n    let elementId: string = null;\n    let sessionId: string | null = null;\n\n    setupWebSocketListener('element', (data) => {\n      elementId = data.id;\n    });\n\n    cy.intercept('POST', '/login').as('login');\n\n    cy.intercept('POST', '/set-session-cookie').as('setSession');\n\n    login('user1', 'user1');\n\n    startConversation();\n\n    let threadId: string = null;\n\n    cy.location('pathname')\n      .should('match', /^\\/thread\\//)\n      .then((pathname) => {\n        const parts = pathname.split('/');\n        threadId = parts[2];\n        expect(threadId).to.match(/^[a-zA-Z0-9_-]+$/);\n      });\n\n    // Wait for session ID capture\n    cy.wait('@setSession').then((interception) => {\n      sessionId = interception.request.body.session_id;\n    });\n\n    cy.wrap(null).should(() => {\n      expect(sessionId).to.not.be.null;\n    });\n\n    cy.then(() => {\n      cy.request({\n        method: 'PUT',\n        url: '/project/element',\n        body: {\n          element: {\n            type: 'custom',\n            id: 'test',\n            name: 'test',\n            display: 'inline',\n            url: 'http://example.org/test.txt'\n          },\n          sessionId: sessionId\n        }\n      });\n    });\n\n    cy.wrap(null).should(() => {\n      expect(elementId).to.exist;\n    });\n\n    cy.then(() => {\n      cy.request(`/project/thread/${threadId}/element/${elementId}`).then(\n        (response) => {\n          expect(response.body.url).to.not.equal('http://example.com/test.txt');\n        }\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/dataframe/main.py",
    "content": "import pandas as pd\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    # Create a sample DataFrame with more than 10 rows to test pagination functionality\n    data = {\n        \"Name\": [\n            \"Alice\",\n            \"David\",\n            \"Charlie\",\n            \"Bob\",\n            \"Eva\",\n            \"Grace\",\n            \"Hannah\",\n            \"Jack\",\n            \"Frank\",\n            \"Kara\",\n            \"Liam\",\n            \"Ivy\",\n            \"Mia\",\n            \"Noah\",\n            \"Olivia\",\n        ],\n        \"Age\": [25, 40, 35, 30, 45, 55, 60, 70, 50, 75, 80, 65, 85, 90, 95],\n        \"City\": [\n            \"New York\",\n            \"Houston\",\n            \"Chicago\",\n            \"Los Angeles\",\n            \"Phoenix\",\n            \"San Antonio\",\n            \"San Diego\",\n            \"San Jose\",\n            \"Philadelphia\",\n            \"Austin\",\n            \"Fort Worth\",\n            \"Dallas\",\n            \"Jacksonville\",\n            \"Columbus\",\n            \"Charlotte\",\n        ],\n        \"Salary\": [\n            70000,\n            100000,\n            90000,\n            80000,\n            110000,\n            130000,\n            140000,\n            160000,\n            120000,\n            170000,\n            180000,\n            150000,\n            190000,\n            200000,\n            210000,\n        ],\n    }\n\n    df = pd.DataFrame(data)\n\n    elements = [cl.Dataframe(data=df, name=\"Dataframe\")]\n\n    await cl.Message(content=\"This message has a Dataframe\", elements=elements).send()\n"
  },
  {
    "path": "cypress/e2e/dataframe/spec.cy.ts",
    "content": "describe('dataframe', () => {\n  it('should be able to display an inline dataframe', () => {\n    // Check if the DataFrame is rendered within the first step\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').first().find('.dataframe').should('have.length', 1);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/edit_message/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def main():\n    await cl.Message(\n        content=f\"Chat context length: {len(cl.chat_context.get())}\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/edit_message/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Edit Message', () => {\n  it('should be able to edit a message', () => {\n    submitMessage('Hello 1');\n    submitMessage('Hello 2');\n\n    cy.get('.step').should('have.length', 4);\n    cy.get('.step').eq(3).should('contain', 'Chat context length: 3');\n\n    cy.get('.step')\n      .eq(0)\n      .trigger('mouseover')\n      .find('.edit-message')\n      .click({ force: true });\n    cy.get('#edit-chat-input').type('Hello 3');\n    cy.get('.step').eq(0).find('.confirm-edit').click({ force: true });\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Chat context length: 1');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/elements/main.py",
    "content": "import os\n\nimport chainlit as cl\n\n# Get the directory where the current script is located\ncurrent_directory = os.path.dirname(os.path.abspath(__file__))\n# Construct the absolute path to the image file\ncat_image_path = os.path.join(current_directory, \"cat.jpeg\")\npdf_path = os.path.join(current_directory, \"dummy.pdf\")\n\n\n@cl.step(type=\"tool\")\nasync def gen_img():\n    return cl.Image(path=cat_image_path, name=\"image1\")\n\n\n@cl.on_chat_start\nasync def start():\n    img = await gen_img()\n\n    # Element should not be inlined or referenced\n    await cl.Message(\n        content=\"Here is image1, a nice image of a cat!\", elements=[img]\n    ).send()\n\n    # Image should be inlined even if not referenced\n    await cl.Message(\n        content=\"Here a nice image of a cat! As well as text1 and text2!\",\n        elements=[\n            cl.Image(path=cat_image_path, name=\"image1\"),\n            cl.Pdf(path=pdf_path, name=\"pdf1\"),\n            cl.Text(\n                content=\"Here is a side text document\", name=\"text1\", display=\"side\"\n            ),\n            cl.Text(\n                content=\"Here is a page text document\", name=\"text2\", display=\"page\"\n            ),\n        ],\n    ).send()\n    # Element references should work even if element names collide\n    await cl.Message(\n        content=\"Here a nice image of a cat! As well as text1 and text2!\",\n        elements=[\n            cl.Image(path=cat_image_path, name=\"image1\"),\n            cl.Pdf(path=pdf_path, name=\"pdf1\"),\n            cl.Text(\n                content=\"Here is a side text document\", name=\"text1\", display=\"side\"\n            ),\n            cl.Text(\n                content=\"Here is a page text document\", name=\"text2\", display=\"page\"\n            ),\n        ],\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/elements/spec.cy.ts",
    "content": "describe('Elements', () => {\n  it('should be able to display inlined, side and page elements', () => {\n    cy.get('.step').eq(0).find('.inline-image').should('have.length', 0);\n    cy.get('.step').eq(0).find('.element-link').should('have.length', 0);\n    cy.get('.step').eq(0).find('.inline-pdf').should('have.length', 0);\n\n    cy.get('.step').eq(1).find('.inline-image').should('have.length', 1);\n\n    cy.get('.step').eq(2).find('.inline-image').should('have.length', 1);\n    cy.get('.step').eq(2).find('.element-link').should('have.length', 2);\n    cy.get('.step').eq(2).find('.inline-pdf').should('have.length', 1);\n\n    cy.get('.step').eq(3).find('.inline-image').should('have.length', 1);\n    cy.get('.step').eq(3).find('.element-link').should('have.length', 2);\n    cy.get('.step').eq(3).find('.inline-pdf').should('have.length', 1);\n\n    // Side\n    cy.get('.step')\n      .eq(2)\n      .find('.element-link')\n      .eq(0)\n      .should('contain', 'text1')\n      .click();\n    const sideViewTitle = cy.get('#side-view-title');\n    sideViewTitle.should('exist');\n    sideViewTitle.should('contain', 'text1');\n\n    const sideViewContent = cy.get('#side-view-content');\n    sideViewContent.should('exist');\n    sideViewContent.should('contain', 'Here is a side text document');\n\n    // Page\n    cy.get('.step')\n      .eq(2)\n      .find('.element-link')\n      .eq(1)\n      .should('contain', 'text2')\n      .click();\n\n    const view = cy.get('#element-view');\n    view.should('exist');\n    view.should('contain', 'text2');\n    view.should('contain', 'Here is a page text document');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/error_handling/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\ndef main():\n    raise Exception(\"This is an error message\")\n"
  },
  {
    "path": "cypress/e2e/error_handling/spec.cy.ts",
    "content": "describe('Error Handling', () => {\n  it('should correctly display errors', () => {\n    cy.get('.step')\n      .should('have.length', 1)\n      .eq(0)\n      .should('contain', 'This is an error message');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/file_element/main.py",
    "content": "import os\n\nimport chainlit as cl\n\n# Get the directory where the current script is located\ncurrent_directory = os.path.dirname(os.path.abspath(__file__))\n# Construct the absolute path to the fixtures directory (two levels up from current_dir)\nfixtures_directory = os.path.abspath(\n    os.path.join(current_directory, \"..\", \"..\", \"fixtures\")\n)\n\n# Construct absolute paths for each file\nmp4_path = os.path.join(fixtures_directory, \"example.mp4\")\njpeg_path = os.path.join(fixtures_directory, \"cat.jpeg\")\npython_file_path = os.path.join(fixtures_directory, \"hello.py\")\nmp3_path = os.path.join(fixtures_directory, \"example.mp3\")\n\n\n@cl.on_chat_start\nasync def start():\n    elements = [\n        cl.File(\n            name=\"example.mp4\",\n            path=mp4_path,\n            mime=\"video/mp4\",\n        ),\n        cl.File(\n            name=\"cat.jpeg\",\n            path=jpeg_path,\n            mime=\"image/jpg\",\n        ),\n        cl.File(\n            name=\"hello.py\",\n            path=python_file_path,\n            mime=\"plain/py\",\n        ),\n        cl.File(\n            name=\"example.mp3\",\n            path=mp3_path,\n            mime=\"audio/mp3\",\n        ),\n    ]\n\n    await cl.Message(\n        content=\"This message has a couple of file element\", elements=elements\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/file_element/spec.cy.ts",
    "content": "describe('file', () => {\n  it('should be able to display a file element', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).find('.inline-file').should('have.length', 4);\n\n    cy.get('.inline-file').should(($files) => {\n      const downloads = $files\n        .map((i, el) => Cypress.$(el).attr('download'))\n        .get();\n\n      expect(downloads).to.have.members([\n        'example.mp4',\n        'cat.jpeg',\n        'hello.py',\n        'example.mp3'\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/header_auth/main.py",
    "content": "import os\nfrom typing import Optional\n\nimport chainlit as cl\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\n\n@cl.header_auth_callback\nasync def header_auth_callback(headers) -> Optional[cl.User]:\n    if headers.get(\"test-header\"):\n        return cl.User(identifier=\"admin\")\n    else:\n        return None\n\n\n@cl.on_chat_start\nasync def on_chat_start():\n    user = cl.user_session.get(\"user\")\n    await cl.Message(f\"Hello {user.identifier}\").send()\n"
  },
  {
    "path": "cypress/e2e/header_auth/spec.cy.ts",
    "content": "describe('Header auth', () => {\n  beforeEach(() => {\n    cy.visit('/');\n  });\n\n  describe('without an authorization header', () => {\n    it('should display an alert message', () => {\n      cy.get('.alert').should('exist');\n    });\n  });\n\n  describe('with authorization header set', () => {\n    const setupInterceptors = () => {\n      cy.intercept('/auth/header', (req) => {\n        req.headers['test-header'] = 'test header value';\n        req.reply();\n      }).as('auth');\n    };\n\n    beforeEach(() => {\n      setupInterceptors();\n    });\n\n    // Tests that verify the user is logged in (applicable both initially and after reload)\n    const shouldBeLoggedIn = () => {\n      it('should not display an alert message', () => {\n        cy.get('.alert').should('not.exist');\n      });\n\n      it(\"should display 'Hello admin'\", () => {\n        cy.get('.step').eq(0).should('contain', 'Hello admin');\n      });\n    };\n\n    // This test only applies to initial login where /auth/header is called\n    it('should have an access_token cookie in /auth/header response', () => {\n      cy.wait('@auth').then((interception) => {\n        expect(interception.response, 'Intercepted response').to.satisfy(\n          () => true\n        );\n        expect(interception.response.statusCode).to.equal(200);\n\n        // Response contains `Authorization` cookie, starting with Bearer\n        expect(interception.response.headers).to.have.property('set-cookie');\n        const cookie = interception.response.headers['set-cookie'][0];\n        expect(cookie).to.contain('access_token');\n      });\n    });\n\n    shouldBeLoggedIn();\n\n    it('should request and have access to /user', () => {\n      // Only intercept /user _after_ we're logged in.\n      cy.wait('@auth').then(() => {\n        cy.intercept('GET', '/user').as('user');\n      });\n      cy.wait('@user').then((interception) => {\n        expect(interception.response, 'Intercepted response').to.satisfy(\n          () => true\n        );\n        expect(interception.response.statusCode).to.equal(200);\n      });\n    });\n\n    describe('after reloading', () => {\n      beforeEach(() => {\n        cy.reload();\n      });\n\n      shouldBeLoggedIn();\n    });\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/llama_index_cb/main.py",
    "content": "from llama_index.core.callbacks.schema import CBEventType, EventPayload\nfrom llama_index.core.llms import ChatMessage, ChatResponse\nfrom llama_index.core.schema import NodeWithScore, TextNode\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    await cl.Message(content=\"LlamaIndexCb\").send()\n\n    cb = cl.LlamaIndexCallbackHandler()\n\n    cb.on_event_start(CBEventType.RETRIEVE, payload={})\n\n    await cl.sleep(0.2)\n\n    cb.on_event_end(\n        CBEventType.RETRIEVE,\n        payload={\n            EventPayload.NODES: [\n                NodeWithScore(node=TextNode(text=\"This is text1\"), score=1)\n            ]\n        },\n    )\n\n    cb.on_event_start(CBEventType.LLM)\n\n    await cl.sleep(0.2)\n\n    response = ChatResponse(message=ChatMessage(content=\"This is the LLM response\"))\n    cb.on_event_end(\n        CBEventType.LLM,\n        payload={\n            EventPayload.RESPONSE: response,\n            EventPayload.PROMPT: \"This is the LLM prompt\",\n        },\n    )\n"
  },
  {
    "path": "cypress/e2e/llama_index_cb/spec.cy.ts",
    "content": "describe('Llama Index Callback', () => {\n  it('should be able to send messages to the UI with prompts and elements', () => {\n    cy.get('.step').should('have.length', 3);\n\n    const toolCall = cy.get('#step-retrieve');\n\n    toolCall.should('exist').click();\n\n    const toolCallContent = toolCall.get('.message-content').eq(0);\n\n    toolCallContent\n      .should('exist')\n      .get('.element-link')\n      .eq(0)\n      .should('contain', 'Source 0');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/modes/main.py",
    "content": "\"\"\"Test app for Modes picker e2e tests.\"\"\"\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    \"\"\"Set up modes for the picker.\"\"\"\n    await cl.context.emitter.set_modes(\n        [\n            cl.Mode(\n                id=\"model\",\n                name=\"Model\",\n                options=[\n                    cl.ModeOption(\n                        id=\"gemini_3_pro\",\n                        name=\"Gemini 3 Pro\",\n                        description=\"Most capable and intelligent\",\n                        icon=\"sparkles\",\n                        default=False,\n                    ),\n                    cl.ModeOption(\n                        id=\"gemini_3_flash\",\n                        name=\"Gemini 3 Flash\",\n                        description=\"Quick and efficient\",\n                        icon=\"bolt\",\n                        default=True,\n                    ),\n                ],\n            ),\n            cl.Mode(\n                id=\"reasoning\",\n                name=\"Reasoning\",\n                options=[\n                    cl.ModeOption(\n                        id=\"high\",\n                        name=\"High\",\n                        description=\"Maximum depth analysis\",\n                        icon=\"flame\",\n                        default=False,\n                    ),\n                    cl.ModeOption(\n                        id=\"medium\",\n                        name=\"Medium\",\n                        description=\"Balanced approach\",\n                        icon=\"scale\",\n                        default=True,\n                    ),\n                    cl.ModeOption(\n                        id=\"low\",\n                        name=\"Low\",\n                        description=\"Quick responses\",\n                        icon=\"rocket\",\n                        default=False,\n                    ),\n                ],\n            ),\n        ]\n    )\n\n\n@cl.on_message\nasync def on_message(message: cl.Message):\n    \"\"\"Echo the message with the selected modes.\"\"\"\n    modes = message.modes or {}\n    selected_model = modes.get(\"model\", \"No model selected\")\n    selected_reasoning = modes.get(\"reasoning\", \"No reasoning selected\")\n    await cl.Message(\n        content=f\"Model: {selected_model}\\nReasoning: {selected_reasoning}\\n\\nYour message: {message.content}\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/modes/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Modes Picker', () => {\n  // Taller viewport reduces header overlap in headless + absolute-positioned menus\n  beforeEach(() => {\n    cy.viewport(1280, 900);\n  });\n\n  it('should display mode pickers when modes are available', () => {\n    // Wait for chat input to be ready (indicates modes are loaded)\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').should('exist');\n    cy.get('#mode-picker-trigger-reasoning').should('exist');\n  });\n\n  it('should show default options selected', () => {\n    cy.get('#chat-input').should('exist');\n\n    // The default model should be visible in the trigger\n    cy.get('#mode-picker-trigger-model').should('contain', 'Gemini 3 Flash');\n    // The default reasoning should be visible\n    cy.get('#mode-picker-trigger-reasoning').should('contain', 'Medium');\n  });\n\n  it('should open dropdown when model picker is clicked', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').should('be.visible');\n  });\n\n  it('should display all model options in dropdown', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').within(() => {\n      cy.contains('Gemini 3 Pro').should('exist');\n      cy.contains('Gemini 3 Flash').should('exist');\n    });\n  });\n\n  it('should display all reasoning options in dropdown', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-reasoning').click();\n    cy.get('#mode-picker-popover-reasoning').within(() => {\n      cy.contains('High').should('exist');\n      cy.contains('Medium').should('exist');\n      cy.contains('Low').should('exist');\n    });\n  });\n\n  it('should show option descriptions in dropdown', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').within(() => {\n      cy.contains('Most capable and intelligent').should('exist');\n      cy.contains('Quick and efficient').should('exist');\n    });\n  });\n\n  it('should select a model option when clicked', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').contains('Gemini 3 Pro').click();\n    cy.get('#mode-picker-trigger-model').should('contain', 'Gemini 3 Pro');\n  });\n\n  it('should select a reasoning option when clicked', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-reasoning').click();\n    cy.get('#mode-picker-popover-reasoning').contains('High').click();\n    cy.get('#mode-picker-trigger-reasoning').should('contain', 'High');\n  });\n\n  it('should persist mode selections across messages', () => {\n    cy.get('#chat-input').should('exist');\n    // Wait for mode pickers to load\n    cy.get('#mode-picker-trigger-model', { timeout: 10000 }).should('exist');\n    cy.get('#mode-picker-trigger-reasoning', { timeout: 10000 }).should(\n      'exist'\n    );\n\n    // Log initial state\n    cy.get('#mode-picker-trigger-model')\n      .invoke('text')\n      .then((text) => {\n        cy.log('Initial model picker text: ' + text);\n      });\n\n    // Select model\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').contains('Gemini 3 Pro').click();\n\n    // Log after model selection\n    cy.get('#mode-picker-trigger-model')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After model selection: ' + text);\n      });\n\n    // Select reasoning\n    cy.get('#mode-picker-trigger-reasoning').click();\n    cy.get('#mode-picker-popover-reasoning').contains('Low').click();\n\n    // Log after reasoning selection\n    cy.get('#mode-picker-trigger-reasoning')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After reasoning selection: ' + text);\n      });\n\n    // Send a message\n    submitMessage('Test message 1');\n\n    // Wait for response (at least 2 steps: user + assistant)\n    cy.get('.step').should('have.length.at.least', 2);\n\n    // Log mode pickers after first message\n    cy.get('#mode-picker-trigger-model')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After first message - model: ' + text);\n      });\n    cy.get('#mode-picker-trigger-reasoning')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After first message - reasoning: ' + text);\n      });\n\n    // Modes should still be selected\n    cy.get('#mode-picker-trigger-model').should('contain', 'Gemini 3 Pro');\n    cy.get('#mode-picker-trigger-reasoning').should('contain', 'Low');\n\n    // Send another message\n    submitMessage('Test message 2');\n\n    // Wait for response (at least 4 steps: 2 user + 2 assistant)\n    cy.get('.step').should('have.length.at.least', 4);\n\n    // Log mode pickers after second message\n    cy.get('#mode-picker-trigger-model')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After second message - model: ' + text);\n      });\n    cy.get('#mode-picker-trigger-reasoning')\n      .invoke('text')\n      .then((text) => {\n        cy.log('After second message - reasoning: ' + text);\n      });\n\n    // Modes should still be selected\n    cy.get('#mode-picker-trigger-model').should('contain', 'Gemini 3 Pro');\n    cy.get('#mode-picker-trigger-reasoning').should('contain', 'Low');\n  });\n\n  it('should send selected modes with message', () => {\n    cy.get('#chat-input').should('exist');\n\n    // Select model\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').contains('Gemini 3 Flash').click();\n\n    // Select reasoning\n    cy.get('#mode-picker-trigger-reasoning').click();\n    cy.get('#mode-picker-popover-reasoning').contains('High').click();\n\n    // Send a message\n    submitMessage('Which modes?');\n\n    // Wait for response and check content\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').last().should('contain', 'gemini_3_flash');\n    cy.get('.step').last().should('contain', 'high');\n  });\n\n  it('should support keyboard navigation', () => {\n    cy.get('#chat-input').should('exist');\n\n    // Focus the model trigger\n    cy.get('#mode-picker-trigger-model').focus();\n\n    // Press Enter to open\n    cy.get('#mode-picker-trigger-model').type('{enter}');\n    cy.get('#mode-picker-popover-model').should('be.visible');\n\n    // Press Escape to close\n    cy.get('#mode-picker-popover-model').type('{esc}');\n    cy.get('#mode-picker-popover-model').should('not.exist');\n  });\n\n  it('should close dropdown after selection', () => {\n    cy.get('#chat-input').should('exist');\n\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').should('be.visible');\n    cy.get('#mode-picker-popover-model').contains('Gemini 3 Pro').click();\n    cy.get('#mode-picker-popover-model').should('not.exist');\n  });\n\n  it('should handle independent mode selections', () => {\n    cy.get('#chat-input').should('exist');\n\n    // Select model without changing reasoning\n    cy.get('#mode-picker-trigger-model').click();\n    cy.get('#mode-picker-popover-model').contains('Gemini 3 Pro').click();\n\n    // Reasoning should still be at default\n    cy.get('#mode-picker-trigger-reasoning').should('contain', 'Medium');\n\n    // Now change reasoning\n    cy.get('#mode-picker-trigger-reasoning').click();\n    cy.get('#mode-picker-popover-reasoning').contains('High').click();\n\n    // Model should still be Gemini 3 Pro\n    cy.get('#mode-picker-trigger-model').should('contain', 'Gemini 3 Pro');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/on_chat_start/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    await cl.Message(\n        content=\"\"\"Hello!\n\n```python\nimport chainlit as cl\n\n@cl.on_chat_start\nasync def main():\n    await cl.Message(\n        content=\"Here is a simple message\",\n    ).send()\n```\"\"\"\n    ).send()\n"
  },
  {
    "path": "cypress/e2e/on_chat_start/spec.cy.ts",
    "content": "describe('on_chat_start', () => {\n  it('should correctly run on_chat_start', () => {\n    const messages = cy.get('.step');\n    messages.should('have.length', 1);\n\n    messages.eq(0).should('contain.text', 'Hello!');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/password_auth/main.py",
    "content": "import os\nfrom typing import Optional\n\nimport chainlit as cl\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\n\n@cl.password_auth_callback\ndef auth_callback(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) == (\"admin\", \"admin\"):\n        return cl.User(identifier=\"admin\")\n    else:\n        return None\n\n\n@cl.on_chat_start\nasync def on_chat_start():\n    user = cl.user_session.get(\"user\")\n    await cl.Message(f\"Hello {user.identifier}\").send()\n"
  },
  {
    "path": "cypress/e2e/password_auth/spec.cy.ts",
    "content": "describe('Password Auth', () => {\n  describe('when unauthenticated', () => {\n    describe('visiting /', () => {\n      beforeEach(() => {\n        cy.intercept('GET', '/user').as('user');\n        cy.visit('/');\n      });\n\n      it('should attempt to and not not have permission to access /user', () => {\n        cy.wait('@user').then((interception) => {\n          expect(interception.response.statusCode).to.equal(401);\n        });\n      });\n\n      it('should redirect to login dialog', () => {\n        cy.location('pathname').should('eq', '/login');\n        cy.get(\"input[name='email']\").should('exist');\n        cy.get(\"input[name='password']\").should('exist');\n      });\n    });\n\n    describe('visiting /login', () => {\n      beforeEach(() => {\n        cy.visit('/login');\n      });\n\n      describe('submitting incorrect credentials', () => {\n        it('should fail to login with wrong credentials', () => {\n          cy.get(\"input[name='email']\").type('user');\n          cy.get(\"input[name='password']\").type('user');\n          cy.get(\"button[type='submit']\").click();\n          cy.get('body').should('contain', 'Unauthorized');\n        });\n      });\n\n      describe('submitting correct credentials', () => {\n        beforeEach(() => {\n          cy.get(\"input[name='email']\").type('admin');\n          cy.get(\"input[name='password']\").type('admin');\n\n          cy.intercept('POST', '/login').as('login');\n          cy.intercept('GET', '/user').as('user');\n          cy.get(\"button[type='submit']\").click();\n        });\n\n        const shouldBeLoggedIn = () => {\n          it('should have an access_token cookie in /login response', () => {\n            cy.wait('@login').then((interception) => {\n              expect(interception.response.statusCode).to.equal(200);\n\n              // Response contains `Authorization` cookie, starting with Bearer\n              expect(interception.response.headers).to.have.property(\n                'set-cookie'\n              );\n              const cookie = interception.response.headers['set-cookie'][0];\n              expect(cookie).to.contain('access_token');\n            });\n          });\n\n          it('should request and have access to /user', () => {\n            cy.wait('@user').then((interception) => {\n              expect(interception.response.statusCode).to.equal(200);\n            });\n          });\n\n          it('should not be on /login', () => {\n            cy.location('pathname').should('not.contain', '/login');\n          });\n\n          it('should not contain a login form', () => {\n            cy.get(\"input[name='email']\").should('not.exist');\n            cy.get(\"input[name='password']\").should('not.exist');\n          });\n\n          it('should show \"Hello admin\"', () => {\n            cy.get('.step').eq(0).should('contain', 'Hello admin');\n          });\n        };\n\n        shouldBeLoggedIn();\n\n        describe('after reloading', () => {\n          beforeEach(() => {\n            cy.reload();\n          });\n\n          shouldBeLoggedIn();\n        });\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/plotly/main.py",
    "content": "import plotly.graph_objects as go\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    fig = go.Figure(\n        data=[go.Bar(y=[2, 1, 3])],\n        layout_title_text=\"A Figure Displayed with fig.show()\",\n    )\n    elements = [cl.Plotly(name=\"chart\", figure=fig)]\n\n    await cl.Message(content=\"This message has a chart\", elements=elements).send()\n"
  },
  {
    "path": "cypress/e2e/plotly/spec.cy.ts",
    "content": "describe('plotly', () => {\n  it('should be able to display an inline chart', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).find('.inline-plotly').should('have.length', 1);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/pyplot/main.py",
    "content": "import matplotlib.pyplot as plt\n\nimport chainlit as cl\n\n\n@cl.on_chat_start\nasync def start():\n    fig, ax = plt.subplots()\n    ax.plot([1, 2, 3, 4], [1, 4, 2, 3])\n    elements = [cl.Pyplot(name=\"chart\", figure=fig)]\n\n    await cl.Message(content=\"This message has a chart\", elements=elements).send()\n"
  },
  {
    "path": "cypress/e2e/pyplot/spec.cy.ts",
    "content": "describe('pyplot', () => {\n  it('should be able to display an inline chart', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).find('.inline-image').should('have.length', 1);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/readme/chainlit_pt-BR.md",
    "content": "# Bem-vindo ao Chainlit! 🚀🤖\n\nOlá, desenvolvedor! 👋 Estamos empolgados em tê-lo a bordo. O Chainlit é uma ferramenta poderosa projetada para ajudá-lo a prototipar, depurar e compartilhar aplicativos construídos em cima de LLMs.\n\n## Links úteis 🔗\n\n- **Documentação:** Comece com a nossa abrangente Documentação do Chainlit 📚\n- **Comunidade no Discord:** Junte-se ao nosso amigável Discord do Chainlit para fazer perguntas, compartilhar seus projetos e se conectar com outros desenvolvedores! 💬\n\nMal podemos esperar para ver o que você cria com o Chainlit! Boa codificação! 💻😊\n\n## Tela de boas-vindas\n\nPara modificar a tela de boas-vindas, edite o arquivo chainlit.md\n na raiz do seu projeto. Se você não quiser uma tela de boas-vindas, basta deixar este arquivo vazio."
  },
  {
    "path": "cypress/e2e/readme/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def on_message(msg):\n    pass\n"
  },
  {
    "path": "cypress/e2e/readme/spec.cy.ts",
    "content": "function openReadme() {\n  cy.get('#readme-button').click();\n}\n\ndescribe('readme_language', () => {\n  it('should show default markdown on open', () => {\n    openReadme();\n    cy.contains('Welcome to Chainlit!');\n  });\n\n  it('should show Portguese markdown on pt-BR language', () => {\n    cy.visit('/', {\n      onBeforeLoad(win) {\n        Object.defineProperty(win.navigator, 'language', {\n          value: 'pt-BR'\n        });\n      }\n    });\n    openReadme();\n    cy.contains('Bem-vindo ao Chainlit!');\n  });\n\n  it('should fallback to default markdown on Klingon language', () => {\n    cy.visit('/', {\n      onBeforeLoad(win) {\n        Object.defineProperty(win.navigator, 'language', {\n          value: 'tlh'\n        });\n      }\n    });\n    openReadme();\n    cy.contains('Welcome to Chainlit!');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/remove_elements/main.py",
    "content": "import os\n\nimport chainlit as cl\n\n# Get the directory where the current script is located\ncurrent_directory = os.path.dirname(os.path.abspath(__file__))\n# Construct the absolute path to the fixtures directory (two levels up from current_dir)\nfixtures_directory = os.path.abspath(\n    os.path.join(current_directory, \"..\", \"..\", \"fixtures\")\n)\n# Construct the absolute path to the image file\ncat_image_path = os.path.join(fixtures_directory, \"cat.jpeg\")\n\n\n@cl.on_chat_start\nasync def start():\n    step_image = cl.Image(\n        name=\"image1\",\n        path=cat_image_path,\n    )\n    msg_image = cl.Image(\n        name=\"image1\",\n        path=cat_image_path,\n    )\n\n    async with cl.Step(type=\"tool\", name=\"tool1\") as step:\n        step.elements = [\n            step_image,\n            cl.Image(name=\"image2\", path=cat_image_path),\n        ]\n        step.output = \"This step has an image\"\n\n    await cl.Message(\n        content=\"This message has an image\",\n        elements=[\n            msg_image,\n            cl.Image(name=\"image2\", path=cat_image_path),\n        ],\n    ).send()\n    await msg_image.remove()\n    await step_image.remove()\n"
  },
  {
    "path": "cypress/e2e/remove_elements/spec.cy.ts",
    "content": "describe('remove_elements', () => {\n  it('should be able to remove elements', () => {\n    cy.get('#step-tool1').should('exist');\n    cy.get('#step-tool1').click();\n    cy.get('#step-tool1')\n      .parent()\n      .parent()\n      .find('.inline-image')\n      .should('have.length', 1);\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).find('.inline-image').should('have.length', 1);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/remove_step/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    msg1 = cl.Message(content=\"Message 1\")\n    await msg1.send()\n\n    await cl.sleep(1)\n\n    async with cl.Step(type=\"tool\", name=\"tool1\") as child1:\n        child1.output = \"Child 1\"\n\n    await cl.sleep(1)\n    await child1.remove()\n\n    msg2 = cl.Message(content=\"Message 2\")\n    await msg2.send()\n\n    await cl.sleep(1)\n    await msg1.remove()\n\n    await cl.sleep(1)\n    await msg2.remove()\n\n    await cl.sleep(1)\n\n    ask_msg = cl.AskUserMessage(\"Message 3\")\n    await ask_msg.send()\n\n    await cl.sleep(1)\n    await ask_msg.remove()\n"
  },
  {
    "path": "cypress/e2e/remove_step/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Remove Step', () => {\n  it('should be able to remove a step', () => {\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).should('contain', 'Message 1');\n\n    cy.get('#step-tool1').should('exist');\n    cy.get('#step-tool1').click();\n    cy.get('.message-content').eq(1).should('contain', 'Child 1');\n\n    cy.get('#step-tool1').should('not.exist');\n\n    cy.get('.step').eq(1).should('contain', 'Message 2');\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).should('contain', 'Message 2');\n    cy.get('.step').should('have.length', 0);\n\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).should('contain', 'Message 3');\n\n    submitMessage('foo');\n\n    cy.get('.step').should('have.length', 1);\n    cy.get('.step').eq(0).should('contain', 'foo');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/sidebar/main.py",
    "content": "import os\n\nimport chainlit as cl\n\n# Get the directory where the current script is located\ncurrent_directory = os.path.dirname(os.path.abspath(__file__))\n# Construct the absolute path to the image and pdf files\ncat_image_path = os.path.join(current_directory, \"cat.jpeg\")\npdf_path = os.path.join(current_directory, \"dummy.pdf\")\n\n\n@cl.on_chat_start\nasync def start():\n    elements = [\n        cl.Image(path=cat_image_path, name=\"image1\"),\n        cl.Pdf(path=pdf_path, name=\"pdf1\"),\n        cl.Text(content=\"Here is a side text document\", name=\"text1\"),\n        cl.Text(content=\"Here is a page text document\", name=\"text2\"),\n    ]\n\n    await cl.ElementSidebar.set_elements(elements)\n    await cl.ElementSidebar.set_title(\"Test title\")\n\n\n@cl.on_message\nasync def message(msg: cl.Message):\n    await cl.ElementSidebar.set_elements([cl.Text(content=\"Text changed!\")])\n    await cl.ElementSidebar.set_title(\"Title changed!\")\n\n    await cl.sleep(2)\n\n    await cl.ElementSidebar.set_elements([])\n"
  },
  {
    "path": "cypress/e2e/sidebar/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Element Sidebar', () => {\n  it('should be able to interact with the element sidebar', () => {\n    // Check initial state\n    cy.get('#side-view-title').should('have.text', 'Test title');\n    cy.get('#side-view-content').find('.inline-image').should('have.length', 1);\n    cy.get('#side-view-content').find('.inline-pdf').should('have.length', 1);\n    cy.get('#side-view-content').find('.inline-text').should('have.length', 2);\n    cy.get('#side-view-content .inline-text')\n      .first()\n      .should('have.text', 'Here is a side text document');\n    cy.get('#side-view-content .inline-text')\n      .eq(1)\n      .should('have.text', 'Here is a page text document');\n\n    // Send a message to trigger updates\n    submitMessage('Update sidebar');\n\n    // Check updated state\n    cy.get('#side-view-title').should('have.text', 'Title changed!');\n    cy.get('#side-view-content').find('.inline-image').should('have.length', 0);\n    cy.get('#side-view-content').find('.inline-pdf').should('have.length', 0);\n    cy.get('#side-view-content').find('.inline-text').should('have.length', 1);\n    cy.get('#side-view-content .inline-text')\n      .first()\n      .should('have.text', 'Text changed!');\n\n    // Wait for the sidebar to close\n    cy.wait(2500);\n\n    // Check that the sidebar is closed\n    cy.get('#side-view-content').should('not.exist');\n    cy.get('#side-view-title').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/starters/main.py",
    "content": "import chainlit as cl\n\n\n@cl.set_starters\nasync def starters():\n    return [\n        cl.Starter(label=\"test1\", message=\"Running starter 1\"),\n        cl.Starter(label=\"test2\", message=\"Running starter 2\"),\n        cl.Starter(label=\"test3\", message=\"Running starter 3\"),\n    ]\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    await cl.Message(msg.content).send()\n"
  },
  {
    "path": "cypress/e2e/starters/spec.cy.ts",
    "content": "describe('Starters', () => {\n  it('should be able to use a starter', () => {\n    cy.wait(1000);\n    cy.get('#starter-test1').should('exist').click();\n    cy.get('.step').should('have.length', 2);\n\n    cy.get('.step').eq(0).contains('Running starter 1');\n    cy.get('.step').eq(1).contains('Running starter 1');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/starters_categories/main.py",
    "content": "from typing import Optional\n\nimport chainlit as cl\n\n\n@cl.set_starter_categories\nasync def starter_categories(user: Optional[cl.User] = None):\n    return [\n        cl.StarterCategory(\n            label=\"Creative\",\n            starters=[\n                cl.Starter(label=\"poem\", message=\"Write a poem\"),\n                cl.Starter(label=\"story\", message=\"Write a story\"),\n            ],\n        ),\n        cl.StarterCategory(\n            label=\"Educational\",\n            starters=[\n                cl.Starter(label=\"explain\", message=\"Explain something\"),\n            ],\n        ),\n    ]\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    await cl.Message(msg.content).send()\n"
  },
  {
    "path": "cypress/e2e/starters_categories/spec.cy.ts",
    "content": "describe('Starters with Categories', () => {\n  it('should display category buttons', () => {\n    cy.wait(1000);\n    cy.get('#starters').should('exist');\n\n    cy.contains('button', 'Creative').should('exist');\n    cy.contains('button', 'Educational').should('exist');\n  });\n\n  it('should show starters when category is clicked', () => {\n    cy.wait(1000);\n    cy.contains('button', 'Creative').click();\n    cy.get('#starter-poem').should('exist');\n    cy.get('#starter-story').should('exist');\n  });\n\n  it('should be able to use a starter from a category', () => {\n    cy.wait(1000);\n    cy.contains('button', 'Creative').click();\n    cy.get('#starter-poem').should('exist').click();\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(0).contains('Write a poem');\n  });\n\n  it('should toggle category selection', () => {\n    cy.wait(1000);\n    cy.contains('button', 'Creative').click();\n    cy.get('#starter-poem').should('exist');\n\n    cy.contains('button', 'Creative').click();\n    cy.get('#starter-poem').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/step/async-spec.cy.ts",
    "content": "import { tests } from './tests';\n\ndescribe('[async] Step', () => {\n  tests();\n});\n"
  },
  {
    "path": "cypress/e2e/step/main_async.py",
    "content": "import chainlit as cl\n\n\nasync def tool_3():\n    async with cl.Step(name=\"tool3\", type=\"tool\") as s:\n        await cl.sleep(2)\n        s.output = \"Response from tool 3\"\n\n\n@cl.step(name=\"tool2\", type=\"tool\")\nasync def tool_2():\n    await tool_3()\n    await cl.Message(content=\"Message from tool 2\").send()\n    return \"Response from tool 2\"\n\n\n@cl.step(name=\"tool1\", type=\"tool\")\nasync def tool_1():\n    await tool_2()\n    return \"Response from tool 1\"\n\n\n@cl.on_message\nasync def main(message: cl.Message):\n    await tool_1()\n"
  },
  {
    "path": "cypress/e2e/step/main_sync.py",
    "content": "import chainlit as cl\n\n\ndef tool_3():\n    with cl.Step(name=\"tool3\", type=\"tool\") as s:\n        cl.run_sync(cl.sleep(2))\n        s.output = \"Response from tool 3\"\n\n\n@cl.step(name=\"tool2\", type=\"tool\")\ndef tool_2():\n    tool_3()\n    cl.run_sync(cl.Message(content=\"Message from tool 2\").send())\n    return \"Response from tool 2\"\n\n\n@cl.step(name=\"tool1\", type=\"tool\")\ndef tool_1():\n    tool_2()\n    return \"Response from tool 1\"\n\n\n@cl.on_message\nasync def main(message: cl.Message):\n    tool_1()\n"
  },
  {
    "path": "cypress/e2e/step/sync-spec.cy.ts",
    "content": "import { tests } from './tests';\n\ndescribe('[sync] Step', () => {\n  tests();\n});\n"
  },
  {
    "path": "cypress/e2e/step/tests.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\nexport function tests() {\n  it('should be able to nest steps', () => {\n    submitMessage('Hello');\n\n    cy.get('#step-tool1').should('exist').click();\n\n    cy.get('#step-tool2').should('exist').click();\n\n    cy.get('#step-tool3').should('exist');\n\n    cy.get('.step').should('have.length', 5);\n  });\n}\n"
  },
  {
    "path": "cypress/e2e/stop_task/async-spec.cy.ts",
    "content": "import { tests } from './tests';\n\ndescribe('[async] Stop task', () => {\n  tests();\n});\n"
  },
  {
    "path": "cypress/e2e/stop_task/main_async.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def message(message: cl.Message):\n    await cl.Message(content=\"Message 1\").send()\n    await cl.sleep(1)\n    await cl.Message(content=\"Message 2\").send()\n"
  },
  {
    "path": "cypress/e2e/stop_task/main_sync.py",
    "content": "import time\n\nimport chainlit as cl\n\n\ndef sync_function():\n    time.sleep(1)\n\n\n@cl.on_message\nasync def message(message: cl.Message):\n    await cl.Message(content=\"Message 1\").send()\n    await cl.make_async(sync_function)()\n    await cl.Message(content=\"Message 2\").send()\n"
  },
  {
    "path": "cypress/e2e/stop_task/sync-spec.cy.ts",
    "content": "import { tests } from './tests';\n\ndescribe('[sync] Stop task', () => {\n  tests();\n});\n"
  },
  {
    "path": "cypress/e2e/stop_task/tests.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\nexport function tests() {\n  it('should be able to stop a task', () => {\n    submitMessage('Hello');\n    cy.get('#stop-button').should('exist').click();\n    cy.get('#stop-button').should('not.exist');\n\n    cy.get('.step').should('have.length', 3);\n    cy.get('.step').last().should('contain.text', 'Task manually stopped.');\n  });\n}\n"
  },
  {
    "path": "cypress/e2e/streaming/main.py",
    "content": "import chainlit as cl\n\ntoken_list = [\"the \", \"quick \", \"brown \", \"fox\"]\n\nsequence_list = [\"the\", \"the quick\", \"the quick brown\", \"the quick brown fox\"]\n\n\n@cl.on_chat_start\nasync def main():\n    msg = cl.Message(content=\"\")\n    for token in token_list:\n        await msg.stream_token(token)\n        await cl.sleep(0.2)\n\n    await msg.send()\n\n    msg = cl.Message(content=\"\")\n    for seq in sequence_list:\n        await msg.stream_token(token=seq, is_sequence=True)\n        await cl.sleep(0.2)\n\n    await msg.send()\n\n    step = cl.Step(type=\"tool\", name=\"tool1\")\n    for token in token_list:\n        await step.stream_token(token)\n        await cl.sleep(0.2)\n\n    await step.send()\n"
  },
  {
    "path": "cypress/e2e/streaming/spec.cy.ts",
    "content": "const tokenList = ['the', 'quick', 'brown', 'fox'];\n\nfunction messageStream(index: number) {\n  for (const token of tokenList) {\n    cy.get('.step').eq(index).should('contain', token);\n  }\n  cy.get('.step').eq(index).should('contain', tokenList.join(' '));\n}\n\nfunction toolStream(tool: string) {\n  const toolCall = cy.get(`#step-${tool}`);\n  toolCall.click();\n  for (const token of tokenList) {\n    toolCall.parent().parent().should('contain', token);\n  }\n  toolCall.parent().parent().should('contain', tokenList.join(' '));\n}\n\ndescribe('Streaming', () => {\n  it('should be able to stream a message', () => {\n    cy.get('.step').should('have.length', 1);\n\n    messageStream(0);\n\n    cy.get('.step').should('have.length', 1);\n\n    messageStream(1);\n\n    cy.get('.step').should('have.length', 2);\n\n    toolStream('tool1');\n\n    cy.get('.step').should('have.length', 3);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/tasklist/main.py",
    "content": "import chainlit as cl\n\nfake_tasks = [\n    \"Initializing\",\n    \"Processing data\",\n    \"Performing calculations\",\n    \"Making decisions based on calculations\",\n    \"Executing commands\",\n    \"Monitoring system performance\",\n    \"Running diagnostics\",\n    \"Updating software components\",\n    \"Creating reports\",\n    \"Scheduling future tasks\",\n    \"Performing maintenance routines\",\n    \"Optimizing system performance\",\n    \"Troubleshooting issues\",\n    \"Improving algorithms\",\n    \"Wrapping up and preparing for the next tasks\",\n    \"Doing a system backup\",\n    \"Updating the security protocols\",\n    \"Preparing for shutdown\",\n]\n\n# Not a good practice in a normal chainlit server as it's global to all users\n# However it work in a testing scenario where we have just one user\ntask_list = None\n\n\n@cl.on_message\nasync def on_message():\n    # Waiting on a message to remove the tasklist to make sure\n    # all checks are successful before we remove it\n    await task_list.remove()\n\n\n@cl.on_chat_start\nasync def main():\n    global task_list\n    await cl.sleep(1)\n    task_list = cl.TaskList()\n    task_list.status = \"Running...\"\n    for i in range(17):\n        task = cl.Task(title=fake_tasks[i])\n        await cl.sleep(0.2)\n        await task_list.add_task(task)\n    await task_list.send()\n\n    await cl.sleep(1)\n\n    task_list.tasks[0].status = cl.TaskStatus.RUNNING\n    await task_list.send()\n\n    await cl.sleep(1)\n\n    for i in range(9):\n        task_list.tasks[i].status = cl.TaskStatus.DONE\n        task_list.tasks[i + 1].status = cl.TaskStatus.RUNNING\n        await cl.sleep(0.2)\n        await task_list.send()\n\n    await cl.sleep(1)\n\n    task_list.tasks[9].status = cl.TaskStatus.FAILED\n    await task_list.send()\n"
  },
  {
    "path": "cypress/e2e/tasklist/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('tasklist', () => {\n  it('should display the tasklist ', () => {\n    cy.get('.step').should('have.length', 0);\n    cy.get('.tasklist').should('have.length', 1);\n    cy.get('.tasklist.tasklist-mobile').should('not.exist');\n\n    cy.get('.tasklist').should('be.visible');\n    cy.get('.tasklist .task').should('have.length', 17);\n\n    cy.get('.tasklist .task.task-status-ready').should('have.length', 7);\n    cy.get('.tasklist .task.task-status-running').should('have.length', 0);\n    cy.get('.tasklist .task.task-status-failed').should('have.length', 1);\n    cy.get('.tasklist .task.task-status-done').should('have.length', 9);\n\n    submitMessage('ok');\n\n    cy.get('.tasklist').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/thread_resume/main.py",
    "content": "import os\nfrom typing import Optional, Dict, List\n\nimport chainlit as cl\nimport chainlit.data as cl_data\nfrom chainlit.element import ElementDict, Element\nfrom chainlit.step import StepDict\nfrom chainlit.types import (\n    ThreadDict,\n    Pagination,\n    ThreadFilter,\n    PaginatedResponse,\n    PageInfo,\n    Feedback,\n)\nfrom chainlit.utils import utc_now\n\nos.environ[\"CHAINLIT_AUTH_SECRET\"] = \"SUPER_SECRET\"  # nosec B105\n\nnow = utc_now()\n\n# Simple in-memory persistence for threads per user\nTHREADS: Dict[str, List[ThreadDict]] = {}\n\n\nclass MemoryDataLayer(cl_data.BaseDataLayer):\n    async def get_user(self, identifier: str):\n        return cl.PersistedUser(id=identifier, createdAt=now, identifier=identifier)\n\n    async def create_user(self, user: cl.User):\n        return cl.PersistedUser(\n            id=user.identifier, createdAt=now, identifier=user.identifier\n        )\n\n    async def delete_feedback(\n        self,\n        feedback_id: str,\n    ) -> bool:\n        pass\n\n    async def upsert_feedback(\n        self,\n        feedback: Feedback,\n    ) -> str:\n        pass\n\n    async def create_element(self, element: \"Element\"):\n        pass\n\n    async def get_element(\n        self, thread_id: str, element_id: str\n    ) -> Optional[\"ElementDict\"]:\n        pass\n\n    async def delete_element(self, element_id: str, thread_id: Optional[str] = None):\n        pass\n\n    async def create_step(self, step_dict: \"StepDict\"):\n        pass\n\n    async def update_step(self, step_dict: \"StepDict\"):\n        pass\n\n    async def delete_step(self, step_id: str):\n        pass\n\n    async def get_thread_author(self, thread_id: str) -> str:\n        return (await self.get_thread(thread_id))[\"userIdentifier\"]\n\n    async def delete_thread(self, thread_id: str):\n        for uid, threads in THREADS.items():\n            THREADS[uid] = [t for t in threads if t[\"id\"] != thread_id]\n\n    async def list_threads(\n        self, pagination: Pagination, filters: ThreadFilter\n    ) -> PaginatedResponse[ThreadDict]:\n        user_id = filters.userId or \"\"\n        data = THREADS.get(user_id, [])\n        return PaginatedResponse(\n            data=data,\n            pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None),\n        )\n\n    async def get_thread(self, thread_id: str) -> \"Optional[ThreadDict]\":\n        for threads in THREADS.values():\n            for t in threads:\n                if t[\"id\"] == thread_id:\n                    return t\n        return None\n\n    async def update_thread(\n        self,\n        thread_id: str,\n        name: Optional[str] = None,\n        user_id: Optional[str] = None,\n        metadata: Optional[Dict] = None,\n        tags: Optional[List[str]] = None,\n    ):\n        user_threads = THREADS.setdefault(user_id or \"\", [])\n        thr = next((t for t in user_threads if t[\"id\"] == thread_id), None)\n        if not thr:\n            thr = {\n                \"id\": thread_id,\n                \"createdAt\": utc_now(),\n                \"userId\": user_id,\n                \"userIdentifier\": user_id,\n                \"name\": name or thread_id,\n                \"steps\": [],\n            }\n            user_threads.append(thr)\n        if name:\n            thr[\"name\"] = name\n        if metadata is not None:\n            thr[\"metadata\"] = metadata\n        if tags is not None:\n            thr[\"tags\"] = tags\n\n    async def get_favorite_steps(self, user_id: str) -> List[\"StepDict\"]:\n        return []\n\n    async def build_debug_url(self) -> str:\n        pass\n\n    async def close(self) -> None:\n        pass\n\n\n@cl.data_layer\ndef data_layer():\n    return MemoryDataLayer()\n\n\n@cl.password_auth_callback\ndef auth(username: str, password: str) -> Optional[cl.User]:\n    if (username, password) in [(\"alice\", \"a\"), (\"bob\", \"b\")]:\n        return cl.PersistedUser(id=username, createdAt=now, identifier=username)\n    return None\n\n\n@cl.on_chat_start\nasync def start():\n    await cl.Message(\"Welcome, say hi to start!\").send()\n\n\n@cl.on_chat_resume\nasync def on_resume(thread: ThreadDict):\n    await cl.Message(f\"Resumed: {thread['name']}\").send()\n\n\n@cl.on_message\nasync def on_message(msg: cl.Message):\n    await cl.Message(f\"Echo: {msg.content}\").send()\n"
  },
  {
    "path": "cypress/e2e/thread_resume/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\nconst login = (user: 'alice' | 'bob') => {\n  cy.visit('/');\n  cy.location('pathname').should('eq', '/login');\n\n  cy.get(\"input[name='email']\").clear().type(user);\n  cy.get(\"input[name='password']\").clear().type(((user === 'alice') ? 'a' : 'b'));\n\n  cy.intercept('POST', '/login').as('loginReq');\n  cy.intercept('GET', '/user').as('userReq');\n\n  cy.get(\"button[type='submit']\").click();\n\n  cy.location('pathname', { timeout: 10000 }).should('eq', '/');\n};\n\ndescribe('Thread resume (author)', () => {\n  it('resumes own thread, composer visible, can continue chatting', () => {\n    login('alice');\n\n    // Start a thread\n    submitMessage('hi');\n    cy.location('pathname').should('match', /\\/thread\\//);\n\n    // Reload to trigger resume\n    cy.reload();\n\n    // Composer present and no read-only banner\n    cy.get('#message-composer').should('be.visible');\n    cy.get('[data-testid=\"read-only-banner\"]').should('not.exist');\n\n  // Continue chatting\n  submitMessage('still here');\n  cy.get(\"[data-step-type='assistant_message']\").contains('Echo: still here');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/update_step/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_chat_start\nasync def main():\n    msg = cl.Message(content=\"Hello!\")\n    await msg.send()\n\n    async with cl.Step(type=\"tool\", name=\"tool1\") as step:\n        step.output = \"Foo\"\n\n    await cl.sleep(1)\n    msg.content = \"Hello again!\"\n    await msg.update()\n\n    step.output += \" Bar\"\n    await step.update()\n"
  },
  {
    "path": "cypress/e2e/update_step/spec.cy.ts",
    "content": "describe('Update Step', () => {\n  it('should be able to update a step', () => {\n    cy.get(`#step-tool1`).click();\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(0).should('contain', 'Hello!');\n    cy.get(`#step-tool1`).parent().parent().should('contain', 'Foo');\n\n    cy.get('.step').eq(0).should('contain', 'Hello again!');\n    cy.get(`#step-tool1`).parent().parent().should('contain', 'Foo Bar');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/upload_attachments/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def main(message: cl.Message):\n    await cl.Message(content=f\"Content: {message.content}\").send()\n    # Check if message.elements is not empty and is a list\n    for index, item in enumerate(message.elements):\n        # Send a response for each element\n        await cl.Message(content=f\"Received element {index}: {item.name}\").send()\n"
  },
  {
    "path": "cypress/e2e/upload_attachments/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('Upload attachments', () => {\n  const shouldHaveInlineAttachments = () => {\n    submitMessage('Message with attachments');\n    cy.get('.step').should('have.length', 5);\n    cy.get('.step')\n      .eq(1)\n      .should('contain', 'Content: Message with attachments');\n    cy.get('.step')\n      .eq(2)\n      .should('contain', 'Received element 0: state_of_the_union.txt');\n    cy.get('.step').eq(3).should('contain', 'Received element 1: hello.cpp');\n    cy.get('.step').eq(4).should('contain', 'Received element 2: hello.py');\n\n    cy.get('.step').eq(0).find('.inline-file').should('have.length', 3);\n    cy.get('.inline-file')\n      .eq(0)\n      .should('have.attr', 'download', 'state_of_the_union.txt');\n    cy.get('.inline-file').eq(1).should('have.attr', 'download', 'hello.cpp');\n    cy.get('.inline-file').eq(2).should('have.attr', 'download', 'hello.py');\n  };\n\n  it('Should be able to upload file attachments', () => {\n    cy.fixture('state_of_the_union.txt', 'utf-8').as('txtFile');\n    cy.fixture('hello.cpp', 'utf-8').as('cppFile');\n    cy.fixture('hello.py', 'utf-8').as('pyFile');\n\n    // Wait for the socket connection to be created\n    cy.wait(1000);\n\n    /**\n     * Should be able to upload file from D&D input\n     */\n    cy.get(\"[id='#upload-drop-input']\").should('exist');\n    // Upload a text file\n    cy.get(\"[id='#upload-drop-input']\").selectFile('@txtFile', { force: true });\n    cy.get('#attachments').should('contain', 'state_of_the_union.txt');\n\n    // Upload a C++ file\n    cy.get(\"[id='#upload-drop-input']\").selectFile('@cppFile', { force: true });\n    cy.get('#attachments').should('contain', 'hello.cpp');\n\n    // Upload a python file\n    cy.get(\"[id='#upload-drop-input']\").selectFile('@pyFile', { force: true });\n    cy.get('#attachments').should('contain', 'hello.py');\n\n    shouldHaveInlineAttachments();\n\n    /**\n     * Should be able to upload file from upload button\n     */\n    cy.reload();\n    cy.get('#upload-button').should('exist');\n\n    // Upload a text file\n    cy.get('#upload-button-input').selectFile('@txtFile', { force: true });\n    cy.get('#attachments').should('contain', 'state_of_the_union.txt');\n\n    // Upload a C++ file\n    cy.get('#upload-button-input').selectFile('@cppFile', { force: true });\n    cy.get('#attachments').should('contain', 'hello.cpp');\n\n    // Upload a python file\n    cy.get('#upload-button-input').selectFile('@pyFile', { force: true });\n    cy.get('#attachments').should('contain', 'hello.py');\n\n    shouldHaveInlineAttachments();\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/user_env/.gitignore",
    "content": "!.chainlit/config.toml"
  },
  {
    "path": "cypress/e2e/user_env/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def main():\n    key = \"TEST_KEY\"\n    user_env = cl.user_session.get(\"env\")\n    provided_key = user_env.get(key)\n    await cl.Message(content=f\"Key {key} has value {provided_key}\").send()\n"
  },
  {
    "path": "cypress/e2e/user_env/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\ndescribe('User Env', () => {\n  it('should be able to ask a user for required keys', () => {\n    const key = 'TEST_KEY';\n    const keyValue = 'TEST_VALUE';\n\n    cy.get(`#${key}`).should('exist').type(keyValue);\n\n    cy.get('#submit-env').should('exist').click();\n\n    submitMessage('Hello');\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', keyValue);\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/user_session/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_message\nasync def main(message: cl.Message):\n    prev_msg = cl.user_session.get(\"prev_msg\")\n    await cl.Message(content=f\"Prev message: {prev_msg}\").send()\n    cl.user_session.set(\"prev_msg\", message.content)\n"
  },
  {
    "path": "cypress/e2e/user_session/spec.cy.ts",
    "content": "import { submitMessage } from '../../support/testUtils';\n\nfunction newSession() {\n  cy.get('#header')\n    .get('#new-chat-button')\n    .should('exist')\n    .click({ force: true });\n  cy.get('#new-chat-dialog').should('exist');\n  cy.get('#confirm').should('exist').click();\n\n  cy.get('#new-chat-dialog').should('not.exist');\n}\n\ndescribe('User Session', () => {\n  it('should be able to store data related per user session', () => {\n    submitMessage('Hello 1');\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Prev message: None');\n\n    submitMessage('Hello 2');\n\n    cy.get('.step').should('have.length', 4);\n    cy.get('.step').eq(3).should('contain', 'Prev message: Hello 1');\n\n    newSession();\n\n    submitMessage('Hello 3');\n\n    cy.get('.step').should('have.length', 2);\n    cy.get('.step').eq(1).should('contain', 'Prev message: None');\n\n    submitMessage('Hello 4');\n\n    cy.get('.step').should('have.length', 4);\n    cy.get('.step').eq(3).should('contain', 'Prev message: Hello 3');\n  });\n});\n"
  },
  {
    "path": "cypress/e2e/window_message/main.py",
    "content": "import chainlit as cl\n\n\n@cl.on_window_message\nasync def window_message(message: str):\n    if message.startswith(\"Client: \"):\n        await cl.send_window_message(\"Server: World\")\n\n\n@cl.on_message\nasync def message(message: str):\n    await cl.Message(content=\"ok\").send()\n"
  },
  {
    "path": "cypress/e2e/window_message/public/iframe.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n    <title>Chainlit iframe</title>\n</head>\n<body>\n    <h1>Chainlit iframe</h1>\n    <iframe src=\"http://127.0.0.1:8000/\" id=\"the-frame\" data-cy=\"the-frame\" width=\"100%\" height=\"500px\"></iframe>\n    <div id=\"message\">No message received</div>\n    <script>\n        window.addEventListener('message', function(event) {\n            if (event.data.startsWith(\"Server: \")) {\n                document.getElementById('message').innerText = event.data;\n            }\n        });\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "cypress/e2e/window_message/spec.cy.ts",
    "content": "const getIframeWindow = () => {\n  return cy\n    .get('iframe[data-cy=\"the-frame\"]')\n    .its('0.contentWindow')\n    .should('exist');\n};\n\ndescribe('Window Message', () => {\n  it('should be able to send and receive window messages', () => {\n    cy.visit('/public/iframe.html');\n\n    cy.get('div#message').should('contain', 'No message received');\n\n    getIframeWindow().then((win) => {\n      cy.wait(1000).then(() => {\n        win.postMessage('Client: Hello', '*');\n      });\n    });\n\n    cy.get('div#message').should('contain', 'Server: World');\n  });\n});\n"
  },
  {
    "path": "cypress/fixtures/hello.cpp",
    "content": "// Your First C++ Program\n\n#include <iostream>\n\nint main() {\n    std::cout << \"Hello World!\";\n    return 0;\n}\n"
  },
  {
    "path": "cypress/fixtures/hello.py",
    "content": "print(\"hello world\")\n"
  },
  {
    "path": "cypress/fixtures/state_of_the_union.txt",
    "content": "Madam Speaker, Madam Vice President, our First Lady and Second Gentleman. Members of Congress and the Cabinet. Justices of the Supreme Court. My fellow Americans.  \n\nLast year COVID-19 kept us apart. This year we are finally together again. \n\nTonight, we meet as Democrats Republicans and Independents. But most importantly as Americans. \n\nWith a duty to one another to the American people to the Constitution. \n\nAnd with an unwavering resolve that freedom will always triumph over tyranny. \n\nSix days ago, Russia’s Vladimir Putin sought to shake the foundations of the free world thinking he could make it bend to his menacing ways. But he badly miscalculated. \n\nHe thought he could roll into Ukraine and the world would roll over. Instead he met a wall of strength he never imagined. \n\nHe met the Ukrainian people. \n\nFrom President Zelenskyy to every Ukrainian, their fearlessness, their courage, their determination, inspires the world. \n\nGroups of citizens blocking tanks with their bodies. Everyone from students to retirees teachers turned soldiers defending their homeland. \n\nIn this struggle as President Zelenskyy said in his speech to the European Parliament “Light will win over darkness.” The Ukrainian Ambassador to the United States is here tonight. \n\nLet each of us here tonight in this Chamber send an unmistakable signal to Ukraine and to the world. \n\nPlease rise if you are able and show that, Yes, we the United States of America stand with the Ukrainian people. \n\nThroughout our history we’ve learned this lesson when dictators do not pay a price for their aggression they cause more chaos.   \n\nThey keep moving.   \n\nAnd the costs and the threats to America and the world keep rising.   \n\nThat’s why the NATO Alliance was created to secure peace and stability in Europe after World War 2. \n\nThe United States is a member along with 29 other nations. \n\nIt matters. American diplomacy matters. American resolve matters. \n\nPutin’s latest attack on Ukraine was premeditated and unprovoked. \n\nHe rejected repeated efforts at diplomacy. \n\nHe thought the West and NATO wouldn’t respond. And he thought he could divide us at home. Putin was wrong. We were ready.  Here is what we did.   \n\nWe prepared extensively and carefully. \n\nWe spent months building a coalition of other freedom-loving nations from Europe and the Americas to Asia and Africa to confront Putin. \n\nI spent countless hours unifying our European allies. We shared with the world in advance what we knew Putin was planning and precisely how he would try to falsely justify his aggression.  \n\nWe countered Russia’s lies with truth.   \n\nAnd now that he has acted the free world is holding him accountable. \n\nAlong with twenty-seven members of the European Union including France, Germany, Italy, as well as countries like the United Kingdom, Canada, Japan, Korea, Australia, New Zealand, and many others, even Switzerland. \n\nWe are inflicting pain on Russia and supporting the people of Ukraine. Putin is now isolated from the world more than ever. \n\nTogether with our allies –we are right now enforcing powerful economic sanctions. \n\nWe are cutting off Russia’s largest banks from the international financial system.  \n\nPreventing Russia’s central bank from defending the Russian Ruble making Putin’s $630 Billion “war fund” worthless.   \n\nWe are choking off Russia’s access to technology that will sap its economic strength and weaken its military for years to come.  \n\nTonight I say to the Russian oligarchs and corrupt leaders who have bilked billions of dollars off this violent regime no more. \n\nThe U.S. Department of Justice is assembling a dedicated task force to go after the crimes of Russian oligarchs.  \n\nWe are joining with our European allies to find and seize your yachts your luxury apartments your private jets. We are coming for your ill-begotten gains. \n\nAnd tonight I am announcing that we will join our allies in closing off American air space to all Russian flights – further isolating Russia – and adding an additional squeeze –on their economy. The Ruble has lost 30% of its value. \n\nThe Russian stock market has lost 40% of its value and trading remains suspended. Russia’s economy is reeling and Putin alone is to blame. \n\nTogether with our allies we are providing support to the Ukrainians in their fight for freedom. Military assistance. Economic assistance. Humanitarian assistance. \n\nWe are giving more than $1 Billion in direct assistance to Ukraine. \n\nAnd we will continue to aid the Ukrainian people as they defend their country and to help ease their suffering.  \n\nLet me be clear, our forces are not engaged and will not engage in conflict with Russian forces in Ukraine.  \n\nOur forces are not going to Europe to fight in Ukraine, but to defend our NATO Allies – in the event that Putin decides to keep moving west.  \n\nFor that purpose we’ve mobilized American ground forces, air squadrons, and ship deployments to protect NATO countries including Poland, Romania, Latvia, Lithuania, and Estonia. \n\nAs I have made crystal clear the United States and our Allies will defend every inch of territory of NATO countries with the full force of our collective power.  \n\nAnd we remain clear-eyed. The Ukrainians are fighting back with pure courage. But the next few days weeks, months, will be hard on them.  \n\nPutin has unleashed violence and chaos.  But while he may make gains on the battlefield – he will pay a continuing high price over the long run. \n\nAnd a proud Ukrainian people, who have known 30 years  of independence, have repeatedly shown that they will not tolerate anyone who tries to take their country backwards.  \n\nTo all Americans, I will be honest with you, as I’ve always promised. A Russian dictator, invading a foreign country, has costs around the world. \n\nAnd I’m taking robust action to make sure the pain of our sanctions  is targeted at Russia’s economy. And I will use every tool at our disposal to protect American businesses and consumers. \n\nTonight, I can announce that the United States has worked with 30 other countries to release 60 Million barrels of oil from reserves around the world.  \n\nAmerica will lead that effort, releasing 30 Million barrels from our own Strategic Petroleum Reserve. And we stand ready to do more if necessary, unified with our allies.  \n\nThese steps will help blunt gas prices here at home. And I know the news about what’s happening can seem alarming. \n\nBut I want you to know that we are going to be okay. \n\nWhen the history of this era is written Putin’s war on Ukraine will have left Russia weaker and the rest of the world stronger. \n\nWhile it shouldn’t have taken something so terrible for people around the world to see what’s at stake now everyone sees it clearly. \n\nWe see the unity among leaders of nations and a more unified Europe a more unified West. And we see unity among the people who are gathering in cities in large crowds around the world even in Russia to demonstrate their support for Ukraine.  \n\nIn the battle between democracy and autocracy, democracies are rising to the moment, and the world is clearly choosing the side of peace and security. \n\nThis is a real test. It’s going to take time. So let us continue to draw inspiration from the iron will of the Ukrainian people. \n\nTo our fellow Ukrainian Americans who forge a deep bond that connects our two nations we stand with you. \n\nPutin may circle Kyiv with tanks, but he will never gain the hearts and souls of the Ukrainian people. \n\nHe will never extinguish their love of freedom. He will never weaken the resolve of the free world. \n\nWe meet tonight in an America that has lived through two of the hardest years this nation has ever faced. \n\nThe pandemic has been punishing. \n\nAnd so many families are living paycheck to paycheck, struggling to keep up with the rising cost of food, gas, housing, and so much more. \n\nI understand. \n\nI remember when my Dad had to leave our home in Scranton, Pennsylvania to find work. I grew up in a family where if the price of food went up, you felt it. \n\nThat’s why one of the first things I did as President was fight to pass the American Rescue Plan.  \n\nBecause people were hurting. We needed to act, and we did. \n\nFew pieces of legislation have done more in a critical moment in our history to lift us out of crisis. \n\nIt fueled our efforts to vaccinate the nation and combat COVID-19. It delivered immediate economic relief for tens of millions of Americans.  \n\nHelped put food on their table, keep a roof over their heads, and cut the cost of health insurance. \n\nAnd as my Dad used to say, it gave people a little breathing room. \n\nAnd unlike the $2 Trillion tax cut passed in the previous administration that benefitted the top 1% of Americans, the American Rescue Plan helped working people—and left no one behind. \n\nAnd it worked. It created jobs. Lots of jobs. \n\nIn fact—our economy created over 6.5 Million new jobs just last year, more jobs created in one year  \nthan ever before in the history of America. \n\nOur economy grew at a rate of 5.7% last year, the strongest growth in nearly 40 years, the first step in bringing fundamental change to an economy that hasn’t worked for the working people of this nation for too long.  \n\nFor the past 40 years we were told that if we gave tax breaks to those at the very top, the benefits would trickle down to everyone else. \n\nBut that trickle-down theory led to weaker economic growth, lower wages, bigger deficits, and the widest gap between those at the top and everyone else in nearly a century. \n\nVice President Harris and I ran for office with a new economic vision for America. \n\nInvest in America. Educate Americans. Grow the workforce. Build the economy from the bottom up  \nand the middle out, not from the top down.  \n\nBecause we know that when the middle class grows, the poor have a ladder up and the wealthy do very well. \n\nAmerica used to have the best roads, bridges, and airports on Earth. \n\nNow our infrastructure is ranked 13th in the world. \n\nWe won’t be able to compete for the jobs of the 21st Century if we don’t fix that. \n\nThat’s why it was so important to pass the Bipartisan Infrastructure Law—the most sweeping investment to rebuild America in history. \n\nThis was a bipartisan effort, and I want to thank the members of both parties who worked to make it happen. \n\nWe’re done talking about infrastructure weeks. \n\nWe’re going to have an infrastructure decade. \n\nIt is going to transform America and put us on a path to win the economic competition of the 21st Century that we face with the rest of the world—particularly with China.  \n\nAs I’ve told Xi Jinping, it is never a good bet to bet against the American people. \n\nWe’ll create good jobs for millions of Americans, modernizing roads, airports, ports, and waterways all across America. \n\nAnd we’ll do it all to withstand the devastating effects of the climate crisis and promote environmental justice. \n\nWe’ll build a national network of 500,000 electric vehicle charging stations, begin to replace poisonous lead pipes—so every child—and every American—has clean water to drink at home and at school, provide affordable high-speed internet for every American—urban, suburban, rural, and tribal communities. \n\n4,000 projects have already been announced. \n\nAnd tonight, I’m announcing that this year we will start fixing over 65,000 miles of highway and 1,500 bridges in disrepair. \n\nWhen we use taxpayer dollars to rebuild America – we are going to Buy American: buy American products to support American jobs. \n\nThe federal government spends about $600 Billion a year to keep the country safe and secure. \n\nThere’s been a law on the books for almost a century \nto make sure taxpayers’ dollars support American jobs and businesses. \n\nEvery Administration says they’ll do it, but we are actually doing it. \n\nWe will buy American to make sure everything from the deck of an aircraft carrier to the steel on highway guardrails are made in America. \n\nBut to compete for the best jobs of the future, we also need to level the playing field with China and other competitors. \n\nThat’s why it is so important to pass the Bipartisan Innovation Act sitting in Congress that will make record investments in emerging technologies and American manufacturing. \n\nLet me give you one example of why it’s so important to pass it. \n\nIf you travel 20 miles east of Columbus, Ohio, you’ll find 1,000 empty acres of land. \n\nIt won’t look like much, but if you stop and look closely, you’ll see a “Field of dreams,” the ground on which America’s future will be built. \n\nThis is where Intel, the American company that helped build Silicon Valley, is going to build its $20 billion semiconductor “mega site”. \n\nUp to eight state-of-the-art factories in one place. 10,000 new good-paying jobs. \n\nSome of the most sophisticated manufacturing in the world to make computer chips the size of a fingertip that power the world and our everyday lives. \n\nSmartphones. The Internet. Technology we have yet to invent. \n\nBut that’s just the beginning. \n\nIntel’s CEO, Pat Gelsinger, who is here tonight, told me they are ready to increase their investment from  \n$20 billion to $100 billion. \n\nThat would be one of the biggest investments in manufacturing in American history. \n\nAnd all they’re waiting for is for you to pass this bill. \n\nSo let’s not wait any longer. Send it to my desk. I’ll sign it.  \n\nAnd we will really take off. \n\nAnd Intel is not alone. \n\nThere’s something happening in America. \n\nJust look around and you’ll see an amazing story. \n\nThe rebirth of the pride that comes from stamping products “Made In America.” The revitalization of American manufacturing.   \n\nCompanies are choosing to build new factories here, when just a few years ago, they would have built them overseas. \n\nThat’s what is happening. Ford is investing $11 billion to build electric vehicles, creating 11,000 jobs across the country. \n\nGM is making the largest investment in its history—$7 billion to build electric vehicles, creating 4,000 jobs in Michigan. \n\nAll told, we created 369,000 new manufacturing jobs in America just last year. \n\nPowered by people I’ve met like JoJo Burgess, from generations of union steelworkers from Pittsburgh, who’s here with us tonight. \n\nAs Ohio Senator Sherrod Brown says, “It’s time to bury the label “Rust Belt.” \n\nIt’s time. \n\nBut with all the bright spots in our economy, record job growth and higher wages, too many families are struggling to keep up with the bills.  \n\nInflation is robbing them of the gains they might otherwise feel. \n\nI get it. That’s why my top priority is getting prices under control. \n\nLook, our economy roared back faster than most predicted, but the pandemic meant that businesses had a hard time hiring enough workers to keep up production in their factories. \n\nThe pandemic also disrupted global supply chains. \n\nWhen factories close, it takes longer to make goods and get them from the warehouse to the store, and prices go up. \n\nLook at cars. \n\nLast year, there weren’t enough semiconductors to make all the cars that people wanted to buy. \n\nAnd guess what, prices of automobiles went up. \n\nSo—we have a choice. \n\nOne way to fight inflation is to drive down wages and make Americans poorer.  \n\nI have a better plan to fight inflation. \n\nLower your costs, not your wages. \n\nMake more cars and semiconductors in America. \n\nMore infrastructure and innovation in America. \n\nMore goods moving faster and cheaper in America. \n\nMore jobs where you can earn a good living in America. \n\nAnd instead of relying on foreign supply chains, let’s make it in America. \n\nEconomists call it “increasing the productive capacity of our economy.” \n\nI call it building a better America. \n\nMy plan to fight inflation will lower your costs and lower the deficit. \n\n17 Nobel laureates in economics say my plan will ease long-term inflationary pressures. Top business leaders and most Americans support my plan. And here’s the plan: \n\nFirst – cut the cost of prescription drugs. Just look at insulin. One in ten Americans has diabetes. In Virginia, I met a 13-year-old boy named Joshua Davis.  \n\nHe and his Dad both have Type 1 diabetes, which means they need insulin every day. Insulin costs about $10 a vial to make.  \n\nBut drug companies charge families like Joshua and his Dad up to 30 times more. I spoke with Joshua’s mom. \n\nImagine what it’s like to look at your child who needs insulin and have no idea how you’re going to pay for it.  \n\nWhat it does to your dignity, your ability to look your child in the eye, to be the parent you expect to be. \n\nJoshua is here with us tonight. Yesterday was his birthday. Happy birthday, buddy.  \n\nFor Joshua, and for the 200,000 other young people with Type 1 diabetes, let’s cap the cost of insulin at $35 a month so everyone can afford it.  \n\nDrug companies will still do very well. And while we’re at it let Medicare negotiate lower prices for prescription drugs, like the VA already does. \n\nLook, the American Rescue Plan is helping millions of families on Affordable Care Act plans save $2,400 a year on their health care premiums. Let’s close the coverage gap and make those savings permanent. \n\nSecond – cut energy costs for families an average of $500 a year by combatting climate change.  \n\nLet’s provide investments and tax credits to weatherize your homes and businesses to be energy efficient and you get a tax credit; double America’s clean energy production in solar, wind, and so much more;  lower the price of electric vehicles, saving you another $80 a month because you’ll never have to pay at the gas pump again. \n\nThird – cut the cost of child care. Many families pay up to $14,000 a year for child care per child.  \n\nMiddle-class and working families shouldn’t have to pay more than 7% of their income for care of young children.  \n\nMy plan will cut the cost in half for most families and help parents, including millions of women, who left the workforce during the pandemic because they couldn’t afford child care, to be able to get back to work. \n\nMy plan doesn’t stop there. It also includes home and long-term care. More affordable housing. And Pre-K for every 3- and 4-year-old.  \n\nAll of these will lower costs. \n\nAnd under my plan, nobody earning less than $400,000 a year will pay an additional penny in new taxes. Nobody.  \n\nThe one thing all Americans agree on is that the tax system is not fair. We have to fix it.  \n\nI’m not looking to punish anyone. But let’s make sure corporations and the wealthiest Americans start paying their fair share. \n\nJust last year, 55 Fortune 500 corporations earned $40 billion in profits and paid zero dollars in federal income tax.  \n\nThat’s simply not fair. That’s why I’ve proposed a 15% minimum tax rate for corporations. \n\nWe got more than 130 countries to agree on a global minimum tax rate so companies can’t get out of paying their taxes at home by shipping jobs and factories overseas. \n\nThat’s why I’ve proposed closing loopholes so the very wealthy don’t pay a lower tax rate than a teacher or a firefighter.  \n\nSo that’s my plan. It will grow the economy and lower costs for families. \n\nSo what are we waiting for? Let’s get this done. And while you’re at it, confirm my nominees to the Federal Reserve, which plays a critical role in fighting inflation.  \n\nMy plan will not only lower costs to give families a fair shot, it will lower the deficit. \n\nThe previous Administration not only ballooned the deficit with tax cuts for the very wealthy and corporations, it undermined the watchdogs whose job was to keep pandemic relief funds from being wasted. \n\nBut in my administration, the watchdogs have been welcomed back. \n\nWe’re going after the criminals who stole billions in relief money meant for small businesses and millions of Americans.  \n\nAnd tonight, I’m announcing that the Justice Department will name a chief prosecutor for pandemic fraud. \n\nBy the end of this year, the deficit will be down to less than half what it was before I took office.  \n\nThe only president ever to cut the deficit by more than one trillion dollars in a single year. \n\nLowering your costs also means demanding more competition. \n\nI’m a capitalist, but capitalism without competition isn’t capitalism. \n\nIt’s exploitation—and it drives up prices. \n\nWhen corporations don’t have to compete, their profits go up, your prices go up, and small businesses and family farmers and ranchers go under. \n\nWe see it happening with ocean carriers moving goods in and out of America. \n\nDuring the pandemic, these foreign-owned companies raised prices by as much as 1,000% and made record profits. \n\nTonight, I’m announcing a crackdown on these companies overcharging American businesses and consumers. \n\nAnd as Wall Street firms take over more nursing homes, quality in those homes has gone down and costs have gone up.  \n\nThat ends on my watch. \n\nMedicare is going to set higher standards for nursing homes and make sure your loved ones get the care they deserve and expect. \n\nWe’ll also cut costs and keep the economy going strong by giving workers a fair shot, provide more training and apprenticeships, hire them based on their skills not degrees. \n\nLet’s pass the Paycheck Fairness Act and paid leave.  \n\nRaise the minimum wage to $15 an hour and extend the Child Tax Credit, so no one has to raise a family in poverty. \n\nLet’s increase Pell Grants and increase our historic support of HBCUs, and invest in what Jill—our First Lady who teaches full-time—calls America’s best-kept secret: community colleges. \n\nAnd let’s pass the PRO Act when a majority of workers want to form a union—they shouldn’t be stopped.  \n\nWhen we invest in our workers, when we build the economy from the bottom up and the middle out together, we can do something we haven’t done in a long time: build a better America. \n\nFor more than two years, COVID-19 has impacted every decision in our lives and the life of the nation. \n\nAnd I know you’re tired, frustrated, and exhausted. \n\nBut I also know this. \n\nBecause of the progress we’ve made, because of your resilience and the tools we have, tonight I can say  \nwe are moving forward safely, back to more normal routines.  \n\nWe’ve reached a new moment in the fight against COVID-19, with severe cases down to a level not seen since last July.  \n\nJust a few days ago, the Centers for Disease Control and Prevention—the CDC—issued new mask guidelines. \n\nUnder these new guidelines, most Americans in most of the country can now be mask free.   \n\nAnd based on the projections, more of the country will reach that point across the next couple of weeks. \n\nThanks to the progress we have made this past year, COVID-19 need no longer control our lives.  \n\nI know some are talking about “living with COVID-19”. Tonight – I say that we will never just accept living with COVID-19. \n\nWe will continue to combat the virus as we do other diseases. And because this is a virus that mutates and spreads, we will stay on guard. \n\nHere are four common sense steps as we move forward safely.  \n\nFirst, stay protected with vaccines and treatments. We know how incredibly effective vaccines are. If you’re vaccinated and boosted you have the highest degree of protection. \n\nWe will never give up on vaccinating more Americans. Now, I know parents with kids under 5 are eager to see a vaccine authorized for their children. \n\nThe scientists are working hard to get that done and we’ll be ready with plenty of vaccines when they do. \n\nWe’re also ready with anti-viral treatments. If you get COVID-19, the Pfizer pill reduces your chances of ending up in the hospital by 90%.  \n\nWe’ve ordered more of these pills than anyone in the world. And Pfizer is working overtime to get us 1 Million pills this month and more than double that next month.  \n\nAnd we’re launching the “Test to Treat” initiative so people can get tested at a pharmacy, and if they’re positive, receive antiviral pills on the spot at no cost.  \n\nIf you’re immunocompromised or have some other vulnerability, we have treatments and free high-quality masks. \n\nWe’re leaving no one behind or ignoring anyone’s needs as we move forward. \n\nAnd on testing, we have made hundreds of millions of tests available for you to order for free.   \n\nEven if you already ordered free tests tonight, I am announcing that you can order more from covidtests.gov starting next week. \n\nSecond – we must prepare for new variants. Over the past year, we’ve gotten much better at detecting new variants. \n\nIf necessary, we’ll be able to deploy new vaccines within 100 days instead of many more months or years.  \n\nAnd, if Congress provides the funds we need, we’ll have new stockpiles of tests, masks, and pills ready if needed. \n\nI cannot promise a new variant won’t come. But I can promise you we’ll do everything within our power to be ready if it does.  \n\nThird – we can end the shutdown of schools and businesses. We have the tools we need. \n\nIt’s time for Americans to get back to work and fill our great downtowns again.  People working from home can feel safe to begin to return to the office.   \n\nWe’re doing that here in the federal government. The vast majority of federal workers will once again work in person. \n\nOur schools are open. Let’s keep it that way. Our kids need to be in school. \n\nAnd with 75% of adult Americans fully vaccinated and hospitalizations down by 77%, most Americans can remove their masks, return to work, stay in the classroom, and move forward safely. \n\nWe achieved this because we provided free vaccines, treatments, tests, and masks. \n\nOf course, continuing this costs money. \n\nI will soon send Congress a request. \n\nThe vast majority of Americans have used these tools and may want to again, so I expect Congress to pass it quickly.   \n\nFourth, we will continue vaccinating the world.     \n\nWe’ve sent 475 Million vaccine doses to 112 countries, more than any other nation. \n\nAnd we won’t stop. \n\nWe have lost so much to COVID-19. Time with one another. And worst of all, so much loss of life. \n\nLet’s use this moment to reset. Let’s stop looking at COVID-19 as a partisan dividing line and see it for what it is: A God-awful disease.  \n\nLet’s stop seeing each other as enemies, and start seeing each other for who we really are: Fellow Americans.  \n\nWe can’t change how divided we’ve been. But we can change how we move forward—on COVID-19 and other issues we must face together. \n\nI recently visited the New York City Police Department days after the funerals of Officer Wilbert Mora and his partner, Officer Jason Rivera. \n\nThey were responding to a 9-1-1 call when a man shot and killed them with a stolen gun. \n\nOfficer Mora was 27 years old. \n\nOfficer Rivera was 22. \n\nBoth Dominican Americans who’d grown up on the same streets they later chose to patrol as police officers. \n\nI spoke with their families and told them that we are forever in debt for their sacrifice, and we will carry on their mission to restore the trust and safety every community deserves. \n\nI’ve worked on these issues a long time. \n\nI know what works: Investing in crime preventionand community police officers who’ll walk the beat, who’ll know the neighborhood, and who can restore trust and safety. \n\nSo let’s not abandon our streets. Or choose between safety and equal justice. \n\nLet’s come together to protect our communities, restore trust, and hold law enforcement accountable. \n\nThat’s why the Justice Department required body cameras, banned chokeholds, and restricted no-knock warrants for its officers. \n\nThat’s why the American Rescue Plan provided $350 Billion that cities, states, and counties can use to hire more police and invest in proven strategies like community violence interruption—trusted messengers breaking the cycle of violence and trauma and giving young people hope.  \n\nWe should all agree: The answer is not to Defund the police. The answer is to FUND the police with the resources and training they need to protect our communities. \n\nI ask Democrats and Republicans alike: Pass my budget and keep our neighborhoods safe.  \n\nAnd I will keep doing everything in my power to crack down on gun trafficking and ghost guns you can buy online and make at home—they have no serial numbers and can’t be traced. \n\nAnd I ask Congress to pass proven measures to reduce gun violence. Pass universal background checks. Why should anyone on a terrorist list be able to purchase a weapon? \n\nBan assault weapons and high-capacity magazines. \n\nRepeal the liability shield that makes gun manufacturers the only industry in America that can’t be sued. \n\nThese laws don’t infringe on the Second Amendment. They save lives. \n\nThe most fundamental right in America is the right to vote – and to have it counted. And it’s under assault. \n\nIn state after state, new laws have been passed, not only to suppress the vote, but to subvert entire elections. \n\nWe cannot let this happen. \n\nTonight. I call on the Senate to: Pass the Freedom to Vote Act. Pass the John Lewis Voting Rights Act. And while you’re at it, pass the Disclose Act so Americans can know who is funding our elections. \n\nTonight, I’d like to honor someone who has dedicated his life to serve this country: Justice Stephen Breyer—an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court. Justice Breyer, thank you for your service. \n\nOne of the most serious constitutional responsibilities a President has is nominating someone to serve on the United States Supreme Court. \n\nAnd I did that 4 days ago, when I nominated Circuit Court of Appeals Judge Ketanji Brown Jackson. One of our nation’s top legal minds, who will continue Justice Breyer’s legacy of excellence. \n\nA former top litigator in private practice. A former federal public defender. And from a family of public school educators and police officers. A consensus builder. Since she’s been nominated, she’s received a broad range of support—from the Fraternal Order of Police to former judges appointed by Democrats and Republicans. \n\nAnd if we are to advance liberty and justice, we need to secure the Border and fix the immigration system. \n\nWe can do both. At our border, we’ve installed new technology like cutting-edge scanners to better detect drug smuggling.  \n\nWe’ve set up joint patrols with Mexico and Guatemala to catch more human traffickers.  \n\nWe’re putting in place dedicated immigration judges so families fleeing persecution and violence can have their cases heard faster. \n\nWe’re securing commitments and supporting partners in South and Central America to host more refugees and secure their own borders. \n\nWe can do all this while keeping lit the torch of liberty that has led generations of immigrants to this land—my forefathers and so many of yours. \n\nProvide a pathway to citizenship for Dreamers, those on temporary status, farm workers, and essential workers. \n\nRevise our laws so businesses have the workers they need and families don’t wait decades to reunite. \n\nIt’s not only the right thing to do—it’s the economically smart thing to do. \n\nThat’s why immigration reform is supported by everyone from labor unions to religious leaders to the U.S. Chamber of Commerce. \n\nLet’s get it done once and for all. \n\nAdvancing liberty and justice also requires protecting the rights of women. \n\nThe constitutional right affirmed in Roe v. Wade—standing precedent for half a century—is under attack as never before. \n\nIf we want to go forward—not backward—we must protect access to health care. Preserve a woman’s right to choose. And let’s continue to advance maternal health care in America. \n\nAnd for our LGBTQ+ Americans, let’s finally get the bipartisan Equality Act to my desk. The onslaught of state laws targeting transgender Americans and their families is wrong. \n\nAs I said last year, especially to our younger transgender Americans, I will always have your back as your President, so you can be yourself and reach your God-given potential. \n\nWhile it often appears that we never agree, that isn’t true. I signed 80 bipartisan bills into law last year. From preventing government shutdowns to protecting Asian-Americans from still-too-common hate crimes to reforming military justice. \n\nAnd soon, we’ll strengthen the Violence Against Women Act that I first wrote three decades ago. It is important for us to show the nation that we can come together and do big things. \n\nSo tonight I’m offering a Unity Agenda for the Nation. Four big things we can do together.  \n\nFirst, beat the opioid epidemic. \n\nThere is so much we can do. Increase funding for prevention, treatment, harm reduction, and recovery.  \n\nGet rid of outdated rules that stop doctors from prescribing treatments. And stop the flow of illicit drugs by working with state and local law enforcement to go after traffickers. \n\nIf you’re suffering from addiction, know you are not alone. I believe in recovery, and I celebrate the 23 million Americans in recovery. \n\nSecond, let’s take on mental health. Especially among our children, whose lives and education have been turned upside down.  \n\nThe American Rescue Plan gave schools money to hire teachers and help students make up for lost learning.  \n\nI urge every parent to make sure your school does just that. And we can all play a part—sign up to be a tutor or a mentor. \n\nChildren were also struggling before the pandemic. Bullying, violence, trauma, and the harms of social media. \n\nAs Frances Haugen, who is here with us tonight, has shown, we must hold social media platforms accountable for the national experiment they’re conducting on our children for profit. \n\nIt’s time to strengthen privacy protections, ban targeted advertising to children, demand tech companies stop collecting personal data on our children. \n\nAnd let’s get all Americans the mental health services they need. More people they can turn to for help, and full parity between physical and mental health care. \n\nThird, support our veterans. \n\nVeterans are the best of us. \n\nI’ve always believed that we have a sacred obligation to equip all those we send to war and care for them and their families when they come home. \n\nMy administration is providing assistance with job training and housing, and now helping lower-income veterans get VA care debt-free.  \n\nOur troops in Iraq and Afghanistan faced many dangers. \n\nOne was stationed at bases and breathing in toxic smoke from “burn pits” that incinerated wastes of war—medical and hazard material, jet fuel, and more. \n\nWhen they came home, many of the world’s fittest and best trained warriors were never the same. \n\nHeadaches. Numbness. Dizziness. \n\nA cancer that would put them in a flag-draped coffin. \n\nI know. \n\nOne of those soldiers was my son Major Beau Biden. \n\nWe don’t know for sure if a burn pit was the cause of his brain cancer, or the diseases of so many of our troops. \n\nBut I’m committed to finding out everything we can. \n\nCommitted to military families like Danielle Robinson from Ohio. \n\nThe widow of Sergeant First Class Heath Robinson.  \n\nHe was born a soldier. Army National Guard. Combat medic in Kosovo and Iraq. \n\nStationed near Baghdad, just yards from burn pits the size of football fields. \n\nHeath’s widow Danielle is here with us tonight. They loved going to Ohio State football games. He loved building Legos with their daughter. \n\nBut cancer from prolonged exposure to burn pits ravaged Heath’s lungs and body. \n\nDanielle says Heath was a fighter to the very end. \n\nHe didn’t know how to stop fighting, and neither did she. \n\nThrough her pain she found purpose to demand we do better. \n\nTonight, Danielle—we are. \n\nThe VA is pioneering new ways of linking toxic exposures to diseases, already helping more veterans get benefits. \n\nAnd tonight, I’m announcing we’re expanding eligibility to veterans suffering from nine respiratory cancers. \n\nI’m also calling on Congress: pass a law to make sure veterans devastated by toxic exposures in Iraq and Afghanistan finally get the benefits and comprehensive health care they deserve. \n\nAnd fourth, let’s end cancer as we know it. \n\nThis is personal to me and Jill, to Kamala, and to so many of you. \n\nCancer is the #2 cause of death in America–second only to heart disease. \n\nLast month, I announced our plan to supercharge  \nthe Cancer Moonshot that President Obama asked me to lead six years ago. \n\nOur goal is to cut the cancer death rate by at least 50% over the next 25 years, turn more cancers from death sentences into treatable diseases.  \n\nMore support for patients and families. \n\nTo get there, I call on Congress to fund ARPA-H, the Advanced Research Projects Agency for Health. \n\nIt’s based on DARPA—the Defense Department project that led to the Internet, GPS, and so much more.  \n\nARPA-H will have a singular purpose—to drive breakthroughs in cancer, Alzheimer’s, diabetes, and more. \n\nA unity agenda for the nation. \n\nWe can do this. \n\nMy fellow Americans—tonight , we have gathered in a sacred space—the citadel of our democracy. \n\nIn this Capitol, generation after generation, Americans have debated great questions amid great strife, and have done great things. \n\nWe have fought for freedom, expanded liberty, defeated totalitarianism and terror. \n\nAnd built the strongest, freest, and most prosperous nation the world has ever known. \n\nNow is the hour. \n\nOur moment of responsibility. \n\nOur test of resolve and conscience, of history itself. \n\nIt is in this moment that our character is formed. Our purpose is found. Our future is forged. \n\nWell I know this nation.  \n\nWe will meet the test. \n\nTo protect freedom and liberty, to expand fairness and opportunity. \n\nWe will save democracy. \n\nAs hard as these times have been, I am more optimistic about America today than I have been my whole life. \n\nBecause I see the future that is within our grasp. \n\nBecause I know there is simply nothing beyond our capacity. \n\nWe are the only nation on Earth that has always turned every crisis we have faced into an opportunity. \n\nThe only nation that can be defined by a single word: possibilities. \n\nSo on this night, in our 245th year as a nation, I have come to report on the State of the Union. \n\nAnd my report is this: the State of the Union is strong—because you, the American people, are strong. \n\nWe are stronger today than we were a year ago. \n\nAnd we will be stronger a year from now than we are today. \n\nNow is our moment to meet and overcome the challenges of our time. \n\nAnd we will, as one people. \n\nOne America. \n\nThe United States of America. \n\nMay God bless you all. May God protect our troops."
  },
  {
    "path": "cypress/support/e2e.ts",
    "content": "import 'cypress-plugin-steps';\n\n/*\n * This is a workaround for the ResizeObserver loop error that occurs in Cypress.\n * See https://github.com/cypress-io/cypress/issues/20341\n * See https://github.com/cypress-io/cypress/issues/29277\n */\nCypress.on('uncaught:exception', (err) => {\n  if (\n    err.message.includes(\n      'ResizeObserver loop completed with undelivered notifications'\n    )\n  ) {\n    return false;\n  }\n});\n\nbeforeEach(() => {\n  cy.visit('/');\n});\n"
  },
  {
    "path": "cypress/support/run.ts",
    "content": "import {\n  ChildProcessWithoutNullStreams,\n  SpawnOptionsWithoutStdio,\n  spawn\n} from 'child_process';\nimport { access } from 'fs/promises';\nimport { dirname, join } from 'path';\n\nexport const runChainlit = async (\n  spec: Cypress.Spec | null = null\n): Promise<ChildProcessWithoutNullStreams> => {\n  const CHAILIT_DIR = join(process.cwd(), 'backend', 'chainlit');\n  const SAMPLE_DIR = join(CHAILIT_DIR, 'sample');\n\n  return new Promise((resolve, reject) => {\n    const testDir = spec ? dirname(spec.absolute) : SAMPLE_DIR;\n    const entryPointFileName = spec\n      ? spec.name.startsWith('async')\n        ? 'main_async.py'\n        : spec.name.startsWith('sync')\n        ? 'main_sync.py'\n        : 'main.py'\n      : 'hello.py';\n\n    const entryPointPath = join(testDir, entryPointFileName);\n\n    if (!access(entryPointPath)) {\n      return reject(\n        new Error(`Entry point file does not exist: ${entryPointPath}`)\n      );\n    }\n\n    const command = 'uv';\n\n    const args = [\n      '--project',\n      CHAILIT_DIR,\n      'run',\n      'chainlit',\n      'run',\n      entryPointPath,\n      '-h',\n      '--ci'\n    ];\n\n    const options: SpawnOptionsWithoutStdio = {\n      env: {\n        ...process.env,\n        CHAINLIT_APP_ROOT: testDir\n      }\n    };\n\n    const chainlit = spawn(command, args, options);\n\n    chainlit.stdout.on('data', (data) => {\n      const output = data.toString();\n      if (output.includes('Your app is available at')) {\n        resolve(chainlit);\n      }\n    });\n\n    chainlit.stderr.on('data', (data) => {\n      console.error(`[Chainlit stderr] ${data}`);\n    });\n\n    chainlit.on('error', (error) => {\n      reject(error.message);\n    });\n\n    chainlit.on('exit', function (code) {\n      reject('Chainlit process exited with code ' + code);\n    });\n  });\n};\n"
  },
  {
    "path": "cypress/support/testUtils.ts",
    "content": "import { IWidgetConfig } from '../../libs/copilot/src/types';\n\nconst resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/;\nCypress.on('uncaught:exception', (err) => {\n  /* returning false here prevents Cypress from failing the test */\n  if (resizeObserverLoopErrRe.test(err.message)) {\n    return false;\n  }\n});\n\nexport function submitMessage(message: string) {\n  cy.get('#chat-input')\n    .should('be.visible')\n    .should('not.be.disabled')\n    .type(message);\n  cy.get('#chat-submit').should('not.be.disabled').click();\n}\n\nexport function openHistory() {\n  cy.get(`#chat-input`).should('not.be.disabled').type(`{upArrow}`);\n}\n\nexport function closeHistory() {\n  cy.get(`body`).click();\n}\n\nexport function loadCopilotScript() {\n  cy.step('Load the copilot script');\n\n  cy.document().then((document) => {\n    document.body.innerHTML = '<div id=\"root\"></div>';\n\n    return new Cypress.Promise((resolve, reject) => {\n      const script = document.createElement('script');\n      script.src = `${document.location.origin}/copilot/index.js`;\n      script.onload = resolve;\n      script.onerror = () =>\n        reject(new Error('Failed to load copilot/index.js'));\n      document.body.appendChild(script);\n    });\n  });\n\n  cy.window().should('have.property', 'mountChainlitWidget');\n}\n\nexport function mountCopilotWidget(widgetConfig?: Partial<IWidgetConfig>) {\n  cy.step('Mount the widget');\n  cy.get('#chainlit-copilot').should('not.exist');\n  cy.window().then((win) => {\n    // @ts-expect-error is not a valid prop\n    win.mountChainlitWidget({\n      ...widgetConfig,\n      chainlitServer: window.location.origin\n    });\n  });\n  cy.get('#chainlit-copilot').should('exist');\n}\n\nexport function colilotShouldBeClosed() {\n  cy.get('#chainlit-copilot-button').should(\n    'have.attr',\n    'aria-expanded',\n    'false'\n  );\n  cy.get('#chainlit-copilot-chat').should('not.exist');\n}\n\nexport function copilotShouldBeOpen() {\n  cy.get('#chainlit-copilot-button').should(\n    'have.attr',\n    'aria-expanded',\n    'true'\n  );\n  cy.get('#chainlit-copilot-chat').should('exist');\n}\n\nexport function openCopilot() {\n  cy.step('Open copilot');\n\n  colilotShouldBeClosed();\n\n  cy.get('#chainlit-copilot-button').click();\n\n  copilotShouldBeOpen();\n}\n\nexport function getCopilotThreadId() {\n  return cy.window().then((win) => {\n    // @ts-expect-error is not a valid prop\n    return win.getChainlitCopilotThreadId();\n  });\n}\n\nexport function clearCopilotThreadId(newThreadId?: string) {\n  return cy.window().then((win) => {\n    // @ts-expect-error is not a valid prop\n    win.clearChainlitCopilotThreadId(newThreadId);\n  });\n}\n\nconst SOCKET_IO_EVENT_PREFIX = '42'; // Engine.IO MESSAGE (4) + Socket.IO EVENT (2)\nconst SOCKET_IO_PREFIX_LENGTH = 2;\n\nexport function setupWebSocketListener(\n  eventType: string,\n  callback: (data: any) => void\n) {\n  cy.on('window:before:load', (win) => {\n    const OriginalWebSocket = win.WebSocket;\n\n    cy.stub(win, 'WebSocket').callsFake(\n      (url: string, protocols?: string | string[]) => {\n        const ws = new OriginalWebSocket(url, protocols);\n\n        ws.addEventListener('message', (event: MessageEvent) => {\n          const data = event.data;\n          if (\n            typeof data === 'string' &&\n            data.startsWith(SOCKET_IO_EVENT_PREFIX)\n          ) {\n            try {\n              const payload = JSON.parse(data.slice(SOCKET_IO_PREFIX_LENGTH));\n              if (payload[0] === eventType) {\n                callback(payload[1]);\n              }\n            } catch (e) {\n              // Ignore parse errors\n            }\n          }\n        });\n\n        return ws;\n      }\n    );\n  });\n}\n"
  },
  {
    "path": "cypress.config.ts",
    "content": "import { defineConfig } from 'cypress';\nimport fkill from 'fkill';\n\nimport { runChainlit } from './cypress/support/run';\n\nexport const CHAINLIT_APP_PORT = 8000;\n\nasync function killChainlit() {\n  await fkill(`:${CHAINLIT_APP_PORT}`, {\n    force: true,\n    silent: true\n  });\n}\n\n['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'].forEach((signal) => {\n  process.on(signal, () => {\n    (async () => {\n      await killChainlit(); // Ensure Chainlit is killed on exit\n\n      const signalMap = { SIGTERM: 15, SIGINT: 2, SIGHUP: 1, SIGBREAK: 21 };\n      process.exit(128 + (signalMap[signal] || 0));\n    })();\n  });\n});\n\nexport default defineConfig({\n  projectId: 'ij1tyk',\n\n  retries: 3,\n\n  viewportWidth: 1200,\n\n  e2e: {\n    defaultCommandTimeout: 30000,\n    baseUrl: `http://127.0.0.1:${CHAINLIT_APP_PORT}`,\n    experimentalInteractiveRunEvents: true,\n    async setupNodeEvents(on, config) {\n      await killChainlit(); // Fallback to ensure no previous instance is running\n      await runChainlit(); // Start Chainlit before running tests as Cypress require\n\n      on('before:spec', async (spec) => {\n        await killChainlit();\n        await runChainlit(spec);\n      });\n\n      on('after:spec', async () => {\n        await killChainlit();\n      });\n\n      on('after:run', async () => {\n        await killChainlit();\n      });\n\n      on('task', {\n        log(message) {\n          console.log(message);\n          return null;\n        },\n        restartChainlit(spec: Cypress.Spec) {\n          return new Promise((resolve) => {\n            killChainlit().then(() => {\n              runChainlit(spec).then(() => {\n                setTimeout(() => {\n                  resolve(null);\n                }, 1000);\n              });\n            });\n          });\n        }\n      });\n\n      return config;\n    }\n  }\n});\n"
  },
  {
    "path": "frontend/.eslintignore",
    "content": "node_modules\ndist"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\ntsconfig.tsbuildinfo\n\nnode_modules\ndist\ndist_embed\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "frontend/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <!-- TAG INJECTION PLACEHOLDER -->\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <!-- FONT START -->\n    <link\n      href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <!-- FONT END -->\n    <link\n      rel=\"stylesheet\"\n      href=\"https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css\"\n    />\n    <!-- JS INJECTION PLACEHOLDER -->\n    <!-- CSS INJECTION PLACEHOLDER -->\n    <script>\n      const global = globalThis;\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"@chainlit/app\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"preinstall\": \"npx only-allow pnpm\",\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint ./src --ext .ts,.tsx && tsc --noemit\",\n    \"format\": \"prettier 'src/**/*.{ts,tsx,css}' --write\",\n    \"test\": \"vitest run\",\n    \"prepublishOnly\": \"pnpm run build && pnpm test\"\n  },\n  \"dependencies\": {\n    \"@chainlit/react-client\": \"workspace:^\",\n    \"@hookform/resolvers\": \"^3.9.1\",\n    \"@radix-ui/react-accordion\": \"^1.2.2\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.4\",\n    \"@radix-ui/react-aspect-ratio\": \"^1.1.1\",\n    \"@radix-ui/react-avatar\": \"^1.1.2\",\n    \"@radix-ui/react-checkbox\": \"^1.1.3\",\n    \"@radix-ui/react-dialog\": \"^1.1.4\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.4\",\n    \"@radix-ui/react-hover-card\": \"^1.1.4\",\n    \"@radix-ui/react-label\": \"^2.1.1\",\n    \"@radix-ui/react-popover\": \"^1.1.4\",\n    \"@radix-ui/react-progress\": \"^1.1.1\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.2\",\n    \"@radix-ui/react-select\": \"^2.1.4\",\n    \"@radix-ui/react-separator\": \"^1.1.1\",\n    \"@radix-ui/react-slider\": \"^1.2.2\",\n    \"@radix-ui/react-slot\": \"^1.1.1\",\n    \"@radix-ui/react-switch\": \"^1.1.2\",\n    \"@radix-ui/react-tabs\": \"^1.1.3\",\n    \"@radix-ui/react-tooltip\": \"^1.1.6\",\n    \"@tanstack/react-table\": \"^8.20.6\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"1.0.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"embla-carousel-react\": \"^8.5.1\",\n    \"highlight.js\": \"^11.9.0\",\n    \"i18next\": \"^23.7.16\",\n    \"lodash\": \"^4.17.21\",\n    \"lucide-react\": \"^0.555.0\",\n    \"plotly.js\": \"^2.27.0\",\n    \"react\": \"^18.3.1\",\n    \"react-day-picker\": \"^9.11.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-dropzone\": \"^14.2.3\",\n    \"react-file-icon\": \"^1.3.0\",\n    \"react-hook-form\": \"^7.54.2\",\n    \"react-hotkeys-hook\": \"^4.4.1\",\n    \"react-i18next\": \"^14.0.0\",\n    \"react-markdown\": \"^9.0.1\",\n    \"react-player\": \"^2.16.0\",\n    \"react-plotly.js\": \"^2.6.0\",\n    \"react-resizable\": \"^3.0.5\",\n    \"react-resizable-panels\": \"^2.1.7\",\n    \"react-router-dom\": \"^6.15.0\",\n    \"react-runner\": \"^1.0.5\",\n    \"recoil\": \"^0.7.7\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"rehype-raw\": \"^7.0.0\",\n    \"remark-directive\": \"^3.0.1\",\n    \"remark-gfm\": \"^4.0.0\",\n    \"remark-math\": \"^6.0.0\",\n    \"sonner\": \"^1.2.3\",\n    \"swr\": \"^2.2.2\",\n    \"tailwind-merge\": \"^2.5.5\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"unist-util-visit\": \"^5.0.0\",\n    \"usehooks-ts\": \"^2.9.1\",\n    \"uuid\": \"^9.0.0\",\n    \"zod\": \"^3.24.1\"\n  },\n  \"devDependencies\": {\n    \"@swc/core\": \"^1.3.86\",\n    \"@testing-library/jest-dom\": \"^5.17.0\",\n    \"@testing-library/react\": \"^14.0.0\",\n    \"@types/draft-js\": \"^0.11.10\",\n    \"@types/lodash\": \"^4.14.199\",\n    \"@types/node\": \"^20.5.7\",\n    \"@types/react\": \"^18.3.1\",\n    \"@types/react-file-icon\": \"^1.0.2\",\n    \"@types/react-plotly.js\": \"^2.6.3\",\n    \"@types/react-resizable\": \"^3.0.4\",\n    \"@types/uuid\": \"^9.0.3\",\n    \"@vitejs/plugin-react\": \"^4.0.4\",\n    \"@vitejs/plugin-react-swc\": \"^3.3.2\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"immutable\": \"^4.3.4\",\n    \"jsdom\": \"^22.1.0\",\n    \"postcss\": \"^8.4.49\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"tslib\": \"^2.6.2\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.4.14\",\n    \"vite-plugin-svgr\": \"^4.2.0\",\n    \"vite-tsconfig-paths\": \"^4.2.0\",\n    \"vitest\": \"^0.34.4\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite@>=4.4.0 <4.4.12\": \">=4.4.12\",\n      \"vite@>=4.0.0 <=4.5.1\": \">=4.5.2\",\n      \"katex@>=0.11.0 <0.16.10\": \">=0.16.10\",\n      \"katex@>=0.15.4 <0.16.10\": \">=0.16.10\",\n      \"katex@>=0.10.0-beta <0.16.10\": \">=0.16.10\",\n      \"vite@>=4.0.0 <=4.5.2\": \">=4.5.3\",\n      \"braces@<3.0.3\": \">=3.0.3\",\n      \"ws@>=8.0.0 <8.17.1\": \">=8.17.1\",\n      \"micromatch@<4.0.8\": \">=4.0.8\",\n      \"vite@>=4.0.0 <4.5.4\": \">=4.5.4\",\n      \"vite@>=4.0.0 <=4.5.3\": \">=4.5.4\",\n      \"rollup@>=3.0.0 <3.29.5\": \">=3.29.5\",\n      \"rollup@>=4.0.0 <4.22.4\": \">=4.22.4\",\n      \"cross-spawn@>=7.0.0 <7.0.5\": \">=7.0.5\"\n    }\n  }\n}\n"
  },
  {
    "path": "frontend/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { useEffect } from 'react';\nimport { RouterProvider } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\nimport { router } from 'router';\n\nimport { useAuth, useChatSession, useConfig } from '@chainlit/react-client';\n\nimport ChatSettingsModal from './components/ChatSettings';\nimport { ThemeProvider } from './components/ThemeProvider';\nimport { Loader } from '@/components/Loader';\nimport { Toaster } from '@/components/ui/sonner';\n\nimport { userEnvState } from 'state/user';\n\ndeclare global {\n  interface Window {\n    cl_shadowRootElement?: HTMLDivElement;\n    transports?: string[];\n    theme?: {\n      light: Record<string, string>;\n      dark: Record<string, string>;\n    };\n  }\n}\n\nfunction App() {\n  const { config } = useConfig();\n\n  const { isAuthenticated, data, isReady } = useAuth();\n  const userEnv = useRecoilValue(userEnvState);\n  const { connect, chatProfile, setChatProfile } = useChatSession();\n\n  const configLoaded = !!config;\n\n  const chatProfileOk = configLoaded\n    ? config.chatProfiles.length\n      ? !!chatProfile\n      : true\n    : false;\n\n  useEffect(() => {\n    if (!isAuthenticated || !isReady || !chatProfileOk) {\n      return;\n    }\n\n    connect({\n      transports: window.transports,\n      userEnv\n    });\n  }, [userEnv, isAuthenticated, connect, isReady, chatProfileOk]);\n\n  useEffect(() => {\n    if (\n      !configLoaded ||\n      !config ||\n      !config.chatProfiles?.length ||\n      chatProfile\n    ) {\n      return;\n    }\n\n    const defaultChatProfile = config.chatProfiles.find(\n      (profile) => profile.default\n    );\n\n    if (defaultChatProfile) {\n      setChatProfile(defaultChatProfile.name);\n    } else {\n      setChatProfile(config.chatProfiles[0].name);\n    }\n  }, [configLoaded, config, chatProfile, setChatProfile]);\n\n  if (!configLoaded && isAuthenticated) return null;\n\n  return (\n    <ThemeProvider\n      storageKey=\"vite-ui-theme\"\n      defaultTheme={data?.default_theme}\n    >\n      <Toaster richColors className=\"toast\" position=\"top-right\" />\n\n      <ChatSettingsModal />\n      <RouterProvider router={router} />\n\n      <div\n        className={cn(\n          'bg-[hsl(var(--background))] flex items-center justify-center fixed size-full p-2 top-0',\n          isReady && 'hidden'\n        )}\n      >\n        <Loader className=\"!size-6\" />\n      </div>\n    </ThemeProvider>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "frontend/src/AppWrapper.tsx",
    "content": "import getRouterBasename from '@/lib/router';\nimport App from 'App';\nimport { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n  useApi,\n  useAuth,\n  useChatInteract,\n  useConfig\n} from '@chainlit/react-client';\n\nexport default function AppWrapper() {\n  const [translationLoaded, setTranslationLoaded] = useState(false);\n  const { isAuthenticated, isReady } = useAuth();\n  const { language: languageInUse } = useConfig();\n  const { i18n } = useTranslation();\n  const { windowMessage } = useChatInteract();\n\n  function handleChangeLanguage(languageBundle: any): void {\n    i18n.addResourceBundle(languageInUse, 'translation', languageBundle);\n    i18n.changeLanguage(languageInUse);\n  }\n\n  const { data: translations } = useApi<any>(\n    `/project/translations?language=${languageInUse}`\n  );\n\n  useEffect(() => {\n    if (!translations) return;\n    handleChangeLanguage(translations.translation);\n    setTranslationLoaded(true);\n  }, [translations]);\n\n  useEffect(() => {\n    const handleWindowMessage = (event: MessageEvent) => {\n      windowMessage(event.data);\n    };\n    window.addEventListener('message', handleWindowMessage);\n    return () => window.removeEventListener('message', handleWindowMessage);\n  }, [windowMessage]);\n\n  if (!translationLoaded) return null;\n\n  if (\n    isReady &&\n    !isAuthenticated &&\n    window.location.pathname !== getRouterBasename() + '/login' &&\n    window.location.pathname !== getRouterBasename() + '/login/callback'\n  ) {\n    window.location.href = getRouterBasename() + '/login';\n  }\n  return <App />;\n}\n"
  },
  {
    "path": "frontend/src/api/index.ts",
    "content": "import getRouterBasename from '@/lib/router';\nimport { toast } from 'sonner';\n\nimport { ChainlitAPI, ClientError } from '@chainlit/react-client';\n\nconst devServer =\n  (import.meta.env.VITE_API_URL || 'http://localhost:8000') +\n  getRouterBasename();\nconst url = import.meta.env.DEV\n  ? devServer\n  : window.origin + getRouterBasename();\nconst serverUrl = new URL(url);\n\nconst httpEndpoint = serverUrl.toString();\n\nconst on401 = () => {\n  if (window.location.pathname !== getRouterBasename() + '/login') {\n    // The credentials aren't correct, remove the token and redirect to login\n    window.location.href = getRouterBasename() + '/login';\n  }\n};\n\nconst onError = (error: ClientError) => {\n  toast.error(error.toString());\n};\n\nclass ExtendedChainlitAPI extends ChainlitAPI {\n  async shareThread(\n    threadId: string,\n    isShared: boolean\n  ): Promise<{ success: boolean }> {\n    const res = await this.put(`/project/thread/share`, {\n      threadId,\n      isShared\n    });\n    return res.json();\n  }\n\n  connectStreamableHttpMCP(\n    sessionId: string,\n    name: string,\n    url: string,\n    headers?: Record<string, string>\n  ) {\n    // Assumes the backend expects { clientType, name, url }\n    return fetch(new URL(\"mcp\", this.httpEndpoint.endsWith(\"/\") ? this.httpEndpoint : `${this.httpEndpoint}/`), {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        ...(sessionId ? { 'x-session-id': sessionId } : {})\n      },\n      body: JSON.stringify({\n        clientType: 'streamable-http',\n        name,\n        url,\n        sessionId,\n        ...(headers ? { headers } : {})\n      })\n    }).then(async (res) => {\n      const data = await res.json();\n      return { success: res.ok, mcp: data.mcp, error: data.detail };\n    });\n  }\n}\n\nexport const apiClient = new ExtendedChainlitAPI(\n  httpEndpoint,\n  'webapp',\n  {}, // Optional - additionalQueryParams property.\n  on401,\n  onError\n);\n"
  },
  {
    "path": "frontend/src/components/Alert.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport React from 'react';\n\ntype AlertVariant = 'info' | 'error';\n\ninterface AlertProps {\n  variant?: AlertVariant;\n  children: React.ReactNode;\n  className?: string;\n  id?: string;\n}\n\nconst variantStyles = {\n  info: {\n    light: {\n      container:\n        'bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-900',\n      icon: 'text-blue-400 dark:text-blue-300',\n      text: 'text-blue-700 dark:text-blue-200'\n    },\n    dark: {\n      container: 'bg-blue-950 border-blue-900',\n      icon: 'text-blue-300',\n      text: 'text-blue-200'\n    }\n  },\n  error: {\n    light: {\n      container: 'bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-900',\n      icon: 'text-red-400 dark:text-red-300',\n      text: 'text-red-700 dark:text-red-200'\n    },\n    dark: {\n      container: 'bg-red-950 border-red-900',\n      icon: 'text-red-300',\n      text: 'text-red-200'\n    }\n  }\n};\n\nconst icons = {\n  info: (\n    <svg\n      className=\"h-5 w-5\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 20 20\"\n      fill=\"currentColor\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  ),\n  error: (\n    <svg\n      className=\"h-5 w-5\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 20 20\"\n      fill=\"currentColor\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  )\n};\n\nexport const Alert: React.FC<AlertProps> = ({\n  variant = 'info',\n  children,\n  className,\n  id\n}) => {\n  const styles = variantStyles[variant].light;\n\n  return (\n    <div\n      id={id}\n      className={cn(\n        'border rounded-lg p-4 mb-4 alert',\n        styles.container,\n        className\n      )}\n    >\n      <div className=\"flex\">\n        <div className={cn('flex-shrink-0', styles.icon)}>{icons[variant]}</div>\n        <div className=\"ml-3\">\n          <p className={cn('text-sm', styles.text)}>{children}</p>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Alert;\n"
  },
  {
    "path": "frontend/src/components/AudioPresence.tsx",
    "content": "import { hslToHex } from '@/lib/utils';\nimport { useEffect, useMemo, useRef } from 'react';\n\nimport { WavRenderer, useAudio } from '@chainlit/react-client';\n\nimport { useTheme } from '@/components/ThemeProvider';\n\ninterface Props {\n  type: 'client' | 'server';\n  height: number;\n  width: number;\n  barCount: number;\n  barSpacing: number;\n}\n\nexport default function AudioPresence({\n  type,\n  height,\n  width,\n  barCount,\n  barSpacing\n}: Props) {\n  const { variant } = useTheme();\n  const { wavRecorder, wavStreamPlayer, isAiSpeaking } = useAudio();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  const foregroundColor = useMemo(() => {\n    const root = document.documentElement;\n    const styles = getComputedStyle(root);\n    return hslToHex(styles.getPropertyValue('--foreground'));\n  }, [variant]);\n\n  width = type === 'server' && !isAiSpeaking ? height : width;\n\n  useEffect(() => {\n    let isLoaded = true;\n    const dpr = window.devicePixelRatio || 1;\n    let bounceDirection = 1;\n    let bounceFactor = 0;\n\n    const getData = () => {\n      if (type === 'server' && isAiSpeaking) {\n        return wavStreamPlayer.analyser\n          ? wavStreamPlayer.getFrequencies('voice')\n          : { values: new Float32Array([0]) };\n      } else {\n        return wavRecorder.recording\n          ? wavRecorder.getFrequencies('voice')\n          : { values: new Float32Array([0]) };\n      }\n    };\n\n    const render = () => {\n      if (!isLoaded) return;\n      const canvas = canvasRef.current;\n      let ctx: CanvasRenderingContext2D | null = null;\n\n      if (canvas) {\n        // Set the canvas size based on the DPR\n        canvas.width = width * dpr;\n        canvas.height = height * dpr;\n        canvas.style.width = `${width}px`;\n        canvas.style.height = `${height}px`;\n\n        ctx = ctx || canvas.getContext('2d');\n        if (ctx) {\n          // Scale the context to account for the DPR\n          ctx.scale(dpr, dpr);\n\n          ctx.clearRect(0, 0, width, height); // Use CSS dimensions here\n          const result = getData();\n\n          if (type === 'server' && !isAiSpeaking) {\n            // Draw a bouncing circle\n            const amplitude = Math.min(\n              Math.max(0.6, Math.max(...result.values)),\n              1\n            ); // Ensure a minimum amplitude\n            const maxRadius = width / 2;\n            const baseRadius = maxRadius * amplitude;\n            const radius = baseRadius * (0.6 + 0.2 * bounceFactor);\n            const centerX = width / 2;\n            const centerY = height / 2;\n\n            ctx.fillStyle = foregroundColor;\n            ctx.beginPath();\n            ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);\n            ctx.fill();\n\n            const newFactor = bounceFactor + 0.01 * bounceDirection;\n            if (newFactor > 1 || newFactor < 0) {\n              bounceDirection *= -1;\n            }\n            bounceFactor = Math.max(0, Math.min(newFactor, 1));\n          } else {\n            WavRenderer.drawBars(\n              ctx,\n              result.values,\n              width,\n              height,\n              foregroundColor,\n              barCount,\n              0,\n              barSpacing,\n              true\n            );\n          }\n        }\n      }\n      window.requestAnimationFrame(render);\n    };\n    render();\n\n    return () => {\n      isLoaded = false;\n    };\n  }, [\n    height,\n    width,\n    barCount,\n    barSpacing,\n    foregroundColor,\n    wavRecorder,\n    isAiSpeaking\n  ]);\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {type === 'server' && !isAiSpeaking ? (\n        <div className=\"text-muted-foreground\">Listening</div>\n      ) : null}\n      <canvas ref={canvasRef} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/AutoResizeTextarea.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { useEffect, useRef, useState } from 'react';\n\nimport { Textarea } from '@/components/ui/textarea';\n\ninterface Props extends Omit<React.ComponentProps<'textarea'>, 'onPaste'> {\n  maxHeight?: number;\n  placeholder?: string;\n  onPaste?: (event: ClipboardEvent) => void;\n  onEnter?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n  onCompositionStart?: (\n    event: React.CompositionEvent<HTMLTextAreaElement>\n  ) => void;\n  onCompositionEnd?: (\n    event: React.CompositionEvent<HTMLTextAreaElement>\n  ) => void;\n}\n\nconst AutoResizeTextarea = ({\n  maxHeight,\n  onPaste,\n  onEnter,\n  placeholder,\n  className,\n  onKeyDown,\n  onCompositionStart,\n  onCompositionEnd,\n  ...props\n}: Props) => {\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const [isComposing, setIsComposing] = useState(false);\n\n  useEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea || !onPaste) return;\n\n    textarea.addEventListener('paste', onPaste);\n\n    return () => {\n      textarea.removeEventListener('paste', onPaste);\n    };\n  }, [onPaste]);\n\n  useEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea || !maxHeight) return;\n    textarea.style.height = '40px';\n    const newHeight = Math.min(textarea.scrollHeight, maxHeight);\n    textarea.style.height = `${newHeight}px`;\n  }, [props.value, maxHeight]);\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    // Call the parent's onKeyDown first (this is Input's handler)\n    if (onKeyDown) {\n      onKeyDown(event);\n    }\n\n    // Only handle our Enter logic if the event wasn't already handled\n    if (\n      !event.defaultPrevented &&\n      event.key === 'Enter' &&\n      !event.shiftKey &&\n      onEnter &&\n      !isComposing\n    ) {\n      event.preventDefault();\n      onEnter(event);\n    }\n  };\n\n  const handleCompositionStart = (\n    event: React.CompositionEvent<HTMLTextAreaElement>\n  ) => {\n    setIsComposing(true);\n    if (onCompositionStart) {\n      onCompositionStart(event);\n    }\n  };\n\n  const handleCompositionEnd = (\n    event: React.CompositionEvent<HTMLTextAreaElement>\n  ) => {\n    setIsComposing(false);\n    if (onCompositionEnd) {\n      onCompositionEnd(event);\n    }\n  };\n\n  return (\n    <Textarea\n      ref={textareaRef as any}\n      {...props}\n      onKeyDown={handleKeyDown}\n      onCompositionStart={handleCompositionStart}\n      onCompositionEnd={handleCompositionEnd}\n      className={cn(\n        'p-0 min-h-[40px] h-[40px] rounded-none resize-none border-none overflow-y-auto shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0',\n        className\n      )}\n      placeholder={placeholder}\n      style={{ maxHeight }}\n    />\n  );\n};\n\nexport default AutoResizeTextarea;\n"
  },
  {
    "path": "frontend/src/components/AutoResumeThread.tsx",
    "content": "import { useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  resumeThreadErrorState,\n  useChatInteract,\n  useChatSession,\n  useConfig\n} from '@chainlit/react-client';\n\ninterface Props {\n  id: string;\n}\n\nexport default function AutoResumeThread({ id }: Props) {\n  const navigate = useNavigate();\n  const { config } = useConfig();\n  const { clear, setIdToResume } = useChatInteract();\n  const { session, idToResume } = useChatSession();\n  const [resumeThreadError, setResumeThreadError] = useRecoilState(\n    resumeThreadErrorState\n  );\n\n  useEffect(() => {\n    if (!config?.threadResumable) return;\n    clear();\n    setIdToResume(id);\n    if (!config?.dataPersistence) {\n      navigate('/');\n    }\n  }, [config?.threadResumable, id]);\n\n  useEffect(() => {\n    if (id !== idToResume) {\n      return;\n    }\n    if (session?.error) {\n      toast.error(\"Couldn't resume chat\");\n      navigate('/');\n    }\n  }, [session, idToResume, id]);\n\n  useEffect(() => {\n    if (resumeThreadError) {\n      toast.error(\"Couldn't resume chat: \" + resumeThreadError);\n      navigate('/');\n      setResumeThreadError(undefined);\n    }\n  }, [resumeThreadError]);\n\n  return null;\n}\n"
  },
  {
    "path": "frontend/src/components/BlinkingCursor.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nexport const CURSOR_PLACEHOLDER = '\\u200B';\n\ninterface Props {\n  whitespace?: boolean;\n}\n\nexport default function BlinkingCursor({ whitespace }: Props) {\n  return (\n    <span\n      className={cn(\n        'inline-block h-3.5 w-3.5 bg-foreground rounded-full animate-pulse',\n        whitespace && 'ml-2'\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ButtonLink.tsx",
    "content": "import { useContext } from 'react';\n\nimport { ChainlitContext } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nexport interface ButtonLinkProps {\n  name?: string;\n  displayName?: string;\n  iconUrl?: string;\n  url: string;\n  target?: '_blank' | '_self' | '_parent' | '_top';\n}\n\nexport default function ButtonLink({\n  name,\n  displayName,\n  iconUrl,\n  url,\n  target\n}: ButtonLinkProps) {\n  const apiClient = useContext(ChainlitContext);\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size={displayName ? 'default' : 'icon'}\n            className=\"text-muted-foreground hover:text-muted-foreground\"\n          >\n            <a\n              href={url}\n              target={target ?? '_blank'}\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-1\"\n            >\n              <img\n                src={\n                  iconUrl?.startsWith('/public')\n                    ? apiClient.buildEndpoint(iconUrl)\n                    : iconUrl\n                }\n                className={'h-6 w-6'}\n                alt={name}\n              />\n              {displayName && <span>{displayName}</span>}\n            </a>\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>{name}</TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/ChatSettingsSidebar.tsx",
    "content": "import mapValues from 'lodash/mapValues';\nimport { ArrowLeft } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useRecoilState, useSetRecoilState } from 'recoil';\n\nimport {\n    chatSettingsValueState,\n    useChatData,\n    useChatInteract,\n    useConfig\n} from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { ResizableHandle, ResizablePanel } from '@/components/ui/resizable';\nimport {\n    Sheet,\n    SheetContent,\n    SheetHeader,\n    SheetTitle\n} from '@/components/ui/sheet';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Translator } from 'components/i18n';\n\nimport { useIsMobile } from '@/hooks/use-mobile';\n\nimport { chatSettingsSidebarOpenState } from '@/state/project';\n\nimport { FormInput, TFormInputValue } from './FormInput';\n\nexport default function ChatSettingsSidebar() {\n    const { config } = useConfig();\n    const { chatSettingsValue, chatSettingsInputs, chatSettingsDefaultValue } =\n        useChatData();\n    const { updateChatSettings } = useChatInteract();\n    const [sidebarOpen, setSidebarOpen] = useRecoilState(\n        chatSettingsSidebarOpenState\n    );\n    const isMobile = useIsMobile();\n    const [isVisible, setIsVisible] = useState(false);\n\n    const { handleSubmit, setValue, reset, watch } = useForm({\n        defaultValues: chatSettingsValue\n    });\n    const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);\n\n    useEffect(() => {\n        reset(chatSettingsValue);\n    }, [chatSettingsValue, reset]);\n\n    useEffect(() => {\n        if (config?.ui?.default_chat_settings_open && chatSettingsInputs.length > 0) {\n            setSidebarOpen(true);\n        }\n    }, [config?.ui?.default_chat_settings_open, chatSettingsInputs.length, setSidebarOpen]);\n\n    useEffect(() => {\n        if (sidebarOpen) {\n            requestAnimationFrame(() => {\n                setIsVisible(true);\n            });\n        } else {\n            setIsVisible(false);\n        }\n    }, [sidebarOpen]);\n\n    const handleClose = () => {\n        reset(chatSettingsValue);\n        setSidebarOpen(false);\n    };\n\n    const handleConfirm = handleSubmit((data) => {\n        const processedValues = mapValues(data, (x: TFormInputValue) =>\n            x !== '' ? x : null\n        );\n        updateChatSettings(processedValues);\n        setChatSettingsValue(processedValues);\n        setSidebarOpen(false);\n    });\n\n    const handleReset = () => {\n        reset(chatSettingsDefaultValue);\n    };\n\n    const handleChange = () => { };\n\n    const setFieldValue = (field: string, value: any) => {\n        setValue(field, value);\n    };\n\n    const values = watch();\n    const tabInputs = chatSettingsInputs.filter(\n        (input: any) => Array.isArray(input?.inputs) && input.inputs.length > 0\n    );\n    const regularInputs = chatSettingsInputs.filter(\n        (input: any) => !Array.isArray(input?.inputs) || input.inputs.length === 0\n    );\n    const hasTabs = tabInputs.length > 0;\n    const defaultTab = tabInputs[0]?.id;\n\n    if (!sidebarOpen || chatSettingsInputs.length === 0) return null;\n\n    const settingsContent = (\n        <>\n            {hasTabs ? (\n                <Tabs\n                    defaultValue={defaultTab}\n                    className=\"flex flex-col flex-grow min-h-0\"\n                >\n                    <TabsList className=\"w-full flex justify-start flex-wrap h-auto\">\n                        {tabInputs.map((tab: any) => (\n                            <TabsTrigger key={tab.id} value={tab.id}>\n                                {tab.label ?? tab.id}\n                            </TabsTrigger>\n                        ))}\n                    </TabsList>\n                    {tabInputs.map((tab: any) => (\n                        <TabsContent\n                            key={tab.id}\n                            value={tab.id}\n                            className=\"data-[state=active]:flex flex-col flex-grow overflow-y-auto gap-4 p-1 mt-4\"\n                        >\n                            {tab.inputs?.map((input: any) => (\n                                <FormInput\n                                    key={input.id}\n                                    element={{\n                                        ...input,\n                                        value: values[input.id],\n                                        onChange: handleChange,\n                                        setField: setFieldValue\n                                    }}\n                                />\n                            ))}\n                        </TabsContent>\n                    ))}\n                </Tabs>\n            ) : (\n                <div className=\"flex flex-col flex-grow overflow-y-auto gap-4 p-1\">\n                    {regularInputs.map((input: any) => (\n                        <FormInput\n                            key={input.id}\n                            element={{\n                                ...input,\n                                value: values[input.id],\n                                onChange: handleChange,\n                                setField: setFieldValue\n                            }}\n                        />\n                    ))}\n                </div>\n            )}\n            <div className=\"flex gap-2 pt-4 border-t\">\n                <Button variant=\"outline\" size=\"sm\" onClick={handleReset}>\n                    <Translator path=\"common.actions.reset\" />\n                </Button>\n                <div className=\"flex-1\" />\n                <Button variant=\"ghost\" size=\"sm\" onClick={handleClose}>\n                    <Translator path=\"common.actions.cancel\" />\n                </Button>\n                <Button size=\"sm\" onClick={handleConfirm} id=\"confirm-sidebar\">\n                    <Translator path=\"common.actions.confirm\" />\n                </Button>\n            </div>\n        </>\n    );\n\n    if (isMobile) {\n        return (\n            <Sheet open onOpenChange={(open) => !open && handleClose()}>\n                <SheetContent className=\"flex flex-col md:hidden\">\n                    <SheetHeader>\n                        <SheetTitle id=\"chat-settings-sidebar-title\">\n                            <Translator path=\"chat.settings.title\" />\n                        </SheetTitle>\n                    </SheetHeader>\n                    <div className=\"overflow-y-auto flex-grow flex flex-col gap-4 mt-4\">\n                        {settingsContent}\n                    </div>\n                </SheetContent>\n            </Sheet>\n        );\n    }\n\n    return (\n        <>\n            <ResizableHandle className=\"sm:hidden md:block bg-transparent\" />\n            <ResizablePanel\n                minSize={15}\n                defaultSize={25}\n                className={`md:flex flex-col flex-grow sm:hidden transform transition-transform duration-300 ease-in-out ${isVisible ? 'translate-x-0' : 'translate-x-full'\n                    }`}\n            >\n                <aside className=\"relative flex-grow overflow-y-auto mr-4 mb-4\">\n                    <Card className=\"overflow-y-auto h-full relative flex flex-col\">\n                        <div\n                            id=\"chat-settings-sidebar-title\"\n                            className=\"text-lg font-semibold text-foreground px-6 py-4 flex items-center\"\n                        >\n                            <Button\n                                className=\"-ml-2\"\n                                onClick={handleClose}\n                                size=\"icon\"\n                                variant=\"ghost\"\n                            >\n                                <ArrowLeft />\n                            </Button>\n                            <Translator path=\"chat.settings.title\" />\n                        </div>\n                        <CardContent\n                            id=\"chat-settings-sidebar-content\"\n                            className=\"flex flex-col flex-grow gap-4 overflow-y-auto\"\n                        >\n                            {settingsContent}\n                        </CardContent>\n                    </Card>\n                </aside>\n            </ResizablePanel>\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/CheckboxInput.tsx",
    "content": "import { IInput } from '@/types';\nimport * as React from 'react';\n\nimport { Checkbox } from '@/components/ui/checkbox';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface CheckboxInputProps extends IInput {\n  checked: boolean;\n  disabled?: boolean;\n  onChange: (checked: boolean) => void;\n  setField?: (field: string, value: boolean, shouldValidate?: boolean) => void;\n}\n\nconst CheckboxInput = ({\n  id,\n  hasError,\n  description,\n  label,\n  tooltip,\n  checked,\n  disabled,\n  onChange,\n  setField\n}: CheckboxInputProps): JSX.Element => {\n  return (\n    <InputStateHandler\n      id={id}\n      hasError={hasError}\n      description={description}\n      tooltip={tooltip}\n    >\n      <div className=\"flex items-center gap-2\">\n        <Checkbox\n          id={id}\n          checked={checked}\n          disabled={disabled}\n          onCheckedChange={(checked) => {\n            onChange(!!checked);\n            setField?.(id, !!checked);\n          }}\n        />\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <label\n                htmlFor={id}\n                className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n              >\n                {label}\n              </label>\n            </TooltipTrigger>\n            <TooltipContent>{tooltip}</TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </div>\n    </InputStateHandler>\n  );\n};\n\nexport { CheckboxInput };\nexport type { CheckboxInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/DatePickerInput.tsx",
    "content": "import { getDateFnsLocale } from '@/i18n/dateLocale';\nimport { cn } from '@/lib/utils';\nimport { IInput } from '@/types';\nimport { format } from 'date-fns';\nimport { Calendar as CalendarIcon, ChevronDownIcon } from 'lucide-react';\nimport { ReactNode, useState } from 'react';\nimport { DateRange } from 'react-day-picker';\n\nimport { Button } from '@/components/ui/button';\nimport { Calendar } from '@/components/ui/calendar';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger\n} from '@/components/ui/popover';\nimport { useTranslation } from 'components/i18n/Translator';\n\nimport { InputStateHandler } from './InputStateHandler';\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\nconst parseDate = (dateStr: string | undefined | null): Date | undefined => {\n  if (!dateStr) return undefined;\n  try {\n    const date = new Date(dateStr);\n    // Check if date is valid (Invalid Date has NaN time)\n    if (isNaN(date.getTime())) {\n      console.warn(`Invalid date string provided: \"${dateStr}\"`);\n      return undefined;\n    }\n    return date;\n  } catch {\n    return undefined;\n  }\n};\n\nconst formatDateValue = (date: Date | undefined): string | undefined => {\n  if (!date) return undefined;\n  return date.toISOString();\n};\n\nconst formatRangeValue = (\n  range: DateRange | undefined\n): [string, string] | undefined => {\n  if (!range?.from) return undefined;\n  return [\n    formatDateValue(range.from)!,\n    formatDateValue(range.to || range.from)!\n  ];\n};\n\nconst getDisabledMatcher = (\n  disabled: boolean | undefined,\n  minDate: Date | undefined,\n  maxDate: Date | undefined\n) => {\n  if (disabled) return true;\n\n  const matchers = [];\n  if (minDate) matchers.push({ before: minDate });\n  if (maxDate) matchers.push({ after: maxDate });\n\n  return matchers.length > 0 ? matchers : undefined;\n};\n\n// ============================================================================\n// Base Component\n// ============================================================================\n\ninterface DatePickerBaseProps extends IInput {\n  isEmpty: boolean;\n  buttonText: ReactNode;\n  calendarContent: ReactNode;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nconst DatePickerBase = ({\n  id,\n  label,\n  description,\n  tooltip,\n  hasError,\n  disabled,\n  isEmpty,\n  buttonText,\n  calendarContent,\n  open,\n  onOpenChange,\n  className\n}: DatePickerBaseProps): JSX.Element => {\n  return (\n    <InputStateHandler\n      id={id}\n      label={label}\n      description={description}\n      tooltip={tooltip}\n      hasError={hasError}\n    >\n      <Popover open={open} onOpenChange={onOpenChange}>\n        <PopoverTrigger asChild>\n          <Button\n            variant=\"outline\"\n            disabled={disabled}\n            data-empty={isEmpty}\n            className={cn(\n              'w-full justify-between text-left font-normal data-[empty=true]:text-muted-foreground px-3 py-2',\n              className\n            )}\n          >\n            <div className=\"flex gap-3\">\n              <CalendarIcon className=\"!size-5\" />\n              {buttonText}\n            </div>\n\n            <ChevronDownIcon className=\"!size-5\" />\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent className=\"w-auto p-0\" align=\"start\">\n          {calendarContent}\n        </PopoverContent>\n      </Popover>\n    </InputStateHandler>\n  );\n};\n\n// ============================================================================\n// Shared Props\n// ============================================================================\n\ninterface DatePickerSharedProps {\n  min_date?: string | null;\n  max_date?: string | null;\n  format?: string | null;\n  placeholder?: string | null;\n  setField?: (field: string, value: any, shouldValidate?: boolean) => void;\n}\n\n// ============================================================================\n// Single Date Picker\n// ============================================================================\n\nexport interface DatePickerSingleProps extends IInput, DatePickerSharedProps {\n  value?: string;\n}\n\nconst DatePickerSingle = ({\n  id,\n  value,\n  min_date,\n  max_date,\n  format: dateFormat,\n  placeholder,\n  setField,\n  ...baseProps\n}: DatePickerSingleProps): JSX.Element => {\n  const { t, i18n } = useTranslation();\n  const [open, setOpen] = useState(false);\n\n  const date = parseDate(value);\n  const minDate = parseDate(min_date);\n  const maxDate = parseDate(max_date);\n  const dateFnsLocale = getDateFnsLocale(i18n.language);\n\n  const defaultPlaceholder =\n    placeholder ?? t('components.DatePickerInput.placeholder.single');\n\n  const handleDateSelect = (newDate: Date | undefined) => {\n    const formattedDate = formatDateValue(newDate);\n    setField?.(id, formattedDate);\n    setOpen(false);\n  };\n\n  const buttonText = date ? (\n    format(date, dateFormat || 'PPP', { locale: dateFnsLocale })\n  ) : (\n    <span>{defaultPlaceholder}</span>\n  );\n\n  const calendarContent = (\n    <Calendar\n      mode=\"single\"\n      selected={date}\n      onSelect={handleDateSelect}\n      disabled={getDisabledMatcher(baseProps.disabled, minDate, maxDate)}\n      locale={dateFnsLocale}\n      showOutsideDays={false}\n      autoFocus\n    />\n  );\n\n  return (\n    <DatePickerBase\n      {...baseProps}\n      id={id}\n      isEmpty={!date}\n      buttonText={buttonText}\n      calendarContent={calendarContent}\n      open={open}\n      onOpenChange={setOpen}\n    />\n  );\n};\n\n// ============================================================================\n// Range Date Picker\n// ============================================================================\n\nexport interface DatePickerRangeProps extends IInput, DatePickerSharedProps {\n  value?: [string, string];\n}\n\nconst DatePickerRange = ({\n  id,\n  value,\n  min_date,\n  max_date,\n  format: dateFormatInput,\n  placeholder,\n  setField,\n  ...baseProps\n}: DatePickerRangeProps): JSX.Element => {\n  const { t, i18n } = useTranslation();\n  const [open, setOpen] = useState(false);\n\n  const dateRange: DateRange | undefined =\n    value && Array.isArray(value)\n      ? {\n          from: parseDate(value[0]),\n          to: parseDate(value[1])\n        }\n      : undefined;\n\n  // Temporary range state for selections before confirmation\n  const [tempRange, setTempRange] = useState<DateRange | undefined>(dateRange);\n\n  const minDate = parseDate(min_date);\n  const maxDate = parseDate(max_date);\n  const dateFnsLocale = getDateFnsLocale(i18n.language);\n\n  const dateFormat = dateFormatInput || 'PPP';\n  const defaultPlaceholder =\n    placeholder ?? t('components.DatePickerInput.placeholder.range');\n\n  // Update temp range when selecting dates (don't commit yet)\n  const handleRangeDateSelect = (newRange: DateRange | undefined) => {\n    setTempRange(newRange);\n  };\n\n  // Confirm button: commit the temp range and close popover\n  const handleConfirm = () => {\n    const formattedRange = formatRangeValue(tempRange);\n    setField?.(id, formattedRange);\n    setOpen(false);\n  };\n\n  // Reset button: clear the temp range\n  const handleReset = () => {\n    setTempRange(undefined);\n  };\n\n  // Update temp range when popover opens to sync with current value\n  const handleOpenChange = (isOpen: boolean) => {\n    setOpen(isOpen);\n    setTempRange(dateRange);\n  };\n\n  const buttonText = tempRange?.from ? (\n    tempRange.to ? (\n      <>\n        {format(tempRange.from, dateFormat, { locale: dateFnsLocale })} -{' '}\n        {format(tempRange.to, dateFormat, { locale: dateFnsLocale })}\n      </>\n    ) : (\n      format(tempRange.from, dateFormat, { locale: dateFnsLocale })\n    )\n  ) : (\n    <span>{defaultPlaceholder}</span>\n  );\n\n  const calendarContent = (\n    <div className=\"flex flex-col\">\n      <Calendar\n        mode=\"range\"\n        defaultMonth={tempRange?.from || dateRange?.from}\n        selected={tempRange}\n        onSelect={handleRangeDateSelect}\n        numberOfMonths={2}\n        disabled={getDisabledMatcher(baseProps.disabled, minDate, maxDate)}\n        locale={dateFnsLocale}\n        autoFocus\n        showOutsideDays={false}\n      />\n      <div className=\"flex items-center justify-end gap-2 border-t p-3\">\n        <Button variant=\"outline\" onClick={handleReset} size=\"sm\">\n          {t('common.actions.reset')}\n        </Button>\n        <Button onClick={handleConfirm} size=\"sm\">\n          {t('common.actions.confirm')}\n        </Button>\n      </div>\n    </div>\n  );\n\n  return (\n    <DatePickerBase\n      {...baseProps}\n      id={id}\n      isEmpty={!dateRange?.from}\n      buttonText={buttonText}\n      calendarContent={calendarContent}\n      open={open}\n      onOpenChange={handleOpenChange}\n    />\n  );\n};\n\n// ============================================================================\n// Main Component (Router)\n// ============================================================================\n\nexport interface DatePickerInputProps extends IInput, DatePickerSharedProps {\n  mode: 'single' | 'range';\n  value?: string | [string, string];\n}\n\nconst DatePickerInput = (props: DatePickerInputProps): JSX.Element => {\n  if (props.mode === 'single') {\n    return (\n      <DatePickerSingle\n        {...props}\n        value={typeof props.value === 'string' ? props.value : undefined}\n      />\n    );\n  }\n\n  return (\n    <DatePickerRange\n      {...props}\n      value={Array.isArray(props.value) ? props.value : undefined}\n    />\n  );\n};\n\nexport { DatePickerInput, DatePickerRange, DatePickerSingle };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/FormInput.tsx",
    "content": "import { IInput } from 'types/Input';\n\nimport { CheckboxInput, CheckboxInputProps } from './CheckboxInput';\nimport { DatePickerInput, DatePickerInputProps } from './DatePickerInput';\nimport { MultiSelectInput, MultiSelectInputProps } from './MultiSelectInput';\nimport { RadioButtonGroup, RadioButtonGroupProps } from './RadioButtonGroup';\nimport { SelectInput, SelectInputProps } from './SelectInput';\nimport { SliderInput, SliderInputProps } from './SliderInput';\nimport { SwitchInput, SwitchInputProps } from './SwitchInput';\nimport { TagsInput, TagsInputProps } from './TagsInput';\nimport { TextInput, TextInputProps } from './TextInput';\n\ntype TFormInputValue = string | number | boolean | string[] | undefined;\n\ninterface IFormInput<T, V extends TFormInputValue> extends IInput {\n  type: T;\n  value?: V;\n  initial?: V;\n  setField?(field: string, value: V, shouldValidate?: boolean): void;\n}\n\ntype TFormInput =\n  | (Omit<SwitchInputProps, 'checked'> & IFormInput<'switch', boolean>)\n  | (Omit<SliderInputProps, 'value'> & IFormInput<'slider', number>)\n  | (Omit<TagsInputProps, 'value'> & IFormInput<'tags', string[]>)\n  | (Omit<SelectInputProps, 'value'> & IFormInput<'select', string>)\n  | (Omit<TextInputProps, 'value'> & IFormInput<'textinput', string>)\n  | (Omit<TextInputProps, 'value'> & IFormInput<'numberinput', number>)\n  | (Omit<MultiSelectInputProps, 'value'> & IFormInput<'multiselect', string[]>)\n  | (Omit<CheckboxInputProps, 'checked'> & IFormInput<'checkbox', boolean>)\n  | (Omit<RadioButtonGroupProps, 'value'> & IFormInput<'radio', string>)\n  | (DatePickerInputProps &\n      IFormInput<'datepicker', string | [string, string]>);\n\nconst FormInput = ({ element }: { element: TFormInput }): JSX.Element => {\n  switch (element?.type) {\n    case 'select':\n      return <SelectInput {...element} value={element.value ?? ''} />;\n    case 'slider':\n      return <SliderInput {...element} value={element.value ?? 0} />;\n    case 'tags':\n      return <TagsInput {...element} value={element.value ?? []} />;\n    case 'switch':\n      return <SwitchInput {...element} checked={!!element.value} />;\n    case 'textinput':\n      return <TextInput {...element} value={element.value ?? ''} />;\n    case 'numberinput':\n      return (\n        <TextInput\n          {...element}\n          type=\"number\"\n          value={element.value?.toString() ?? '0'}\n        />\n      );\n    case 'multiselect':\n      return <MultiSelectInput {...element} value={element.value ?? []} />;\n    case 'checkbox':\n      return <CheckboxInput {...element} checked={!!element.value} />;\n    case 'radio':\n      return <RadioButtonGroup {...element} value={element.value ?? ''} />;\n    case 'datepicker':\n      return <DatePickerInput {...element} value={element.value} />;\n    default:\n      // If the element type is not recognized, we indicate an unimplemented type.\n      // This code path should not normally occur and serves as a fallback.\n      element satisfies never;\n      return <></>;\n  }\n};\n\nexport { FormInput };\nexport type { IFormInput, TFormInput, TFormInputValue };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/InputLabel.tsx",
    "content": "import { InfoIcon } from 'lucide-react';\n\nimport { Label } from '@/components/ui/label';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { NotificationCount, NotificationCountProps } from './NotificationCount';\n\ninterface InputLabelProps {\n  id?: string;\n  label: string | number;\n  tooltip?: string;\n  notificationsProps?: NotificationCountProps;\n}\n\nconst InputLabel = ({\n  id,\n  label,\n  tooltip,\n  notificationsProps\n}: InputLabelProps): JSX.Element => {\n  return (\n    <div className=\"flex justify-between w-full\">\n      <div className=\"flex items-center gap-2\">\n        <Label htmlFor={id} className=\"text-xs font-semibold text-gray-500\">\n          {label}\n        </Label>\n        {tooltip && (\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger>\n                <InfoIcon className=\"h-3 w-3 text-gray-600\" />\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{tooltip}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n      {notificationsProps && <NotificationCount {...notificationsProps} />}\n    </div>\n  );\n};\n\nexport { InputLabel };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/InputStateHandler.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Info } from 'lucide-react';\nimport React from 'react';\n\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { Badge } from '../ui/badge';\n\ninterface NotificationsProps {\n  count?: number;\n  showBadge?: boolean;\n}\n\ninterface InputProps {\n  description?: string;\n  hasError?: boolean;\n  id: string;\n  label?: string;\n  notificationsProps?: NotificationsProps;\n  tooltip?: string;\n  className?: string;\n}\n\ninterface InputStateHandlerProps extends InputProps {\n  children: React.ReactNode;\n}\n\nconst InputStateHandler = ({\n  children,\n  description,\n  id,\n  label,\n  notificationsProps,\n  tooltip,\n  className\n}: InputStateHandlerProps): JSX.Element => {\n  return (\n    <div className={cn('space-y-2', className)}>\n      {label && (\n        <label\n          htmlFor={id}\n          className=\"flex items-center gap-2 text-sm font-medium\"\n        >\n          {label}\n          {tooltip && (\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger type=\"button\">\n                  <Info className=\"text-muted-foreground !size-4\" />\n                </TooltipTrigger>\n                <TooltipContent>{tooltip}</TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          )}\n          {notificationsProps?.showBadge &&\n          typeof notificationsProps.count === 'number' ? (\n            <Badge variant=\"outline\" className=\"ml-auto\">\n              {notificationsProps.count}\n            </Badge>\n          ) : null}\n        </label>\n      )}\n      <div className=\"flex flex-col gap-2\">\n        {children}\n        {description && (\n          <div className=\"text-sm text-muted-foreground\">{description}</div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport { InputStateHandler };\nexport type { InputStateHandlerProps, InputProps, NotificationsProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/MultiSelectInput.tsx",
    "content": "import { IInput } from '@/types';\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { X } from 'lucide-react';\nimport * as React from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { Badge } from '@/components/ui/badge';\nimport {\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList\n} from '@/components/ui/command';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface SelectItemType {\n  label: string;\n  icon?: React.ReactNode;\n  notificationCount?: number;\n  value: string | number;\n}\n\ninterface MultiSelectInputProps extends IInput {\n  items?: SelectItemType[];\n  value?: (string | number)[];\n  onChange: (value: (string | number)[]) => void;\n  setField?: (\n    field: string,\n    value: (string | number)[],\n    shouldValidate?: boolean\n  ) => void;\n  placeholder?: string;\n}\n\nconst MultiSelectInput = ({\n  id,\n  hasError,\n  description,\n  label,\n  tooltip,\n  disabled = false,\n  items = [],\n  value = [],\n  onChange,\n  setField,\n  placeholder\n}: MultiSelectInputProps) => {\n  const { t } = useTranslation();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const [inputValue, setInputValue] = React.useState('');\n\n  const handleSelect = (selectedValue: string | number) => {\n    const newValue = value.includes(selectedValue)\n      ? value.filter((v) => v !== selectedValue)\n      : [...value, selectedValue];\n    onChange(newValue);\n    setField?.(id, newValue);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const input = inputRef.current;\n    if (input) {\n      if (e.key === 'Delete' || e.key === 'Backspace') {\n        if (input.value === '' && value.length > 0) {\n          const newValue = [...value];\n          newValue.pop();\n          onChange(newValue);\n          setField?.(id, newValue);\n        }\n      }\n      if (e.key === 'Escape') {\n        input.blur();\n      }\n    }\n  };\n\n  const selectables = items.filter((item) => !value.includes(item.value));\n\n  return (\n    <InputStateHandler\n      id={id}\n      hasError={hasError}\n      description={description}\n      label={label}\n      tooltip={tooltip}\n    >\n      <Command\n        onKeyDown={handleKeyDown}\n        className=\"overflow-visible bg-transparent\"\n      >\n        <div className=\"group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\">\n          <div className=\"flex flex-wrap gap-1\">\n            {value.map((v) => {\n              const item = items.find((item) => item.value === v);\n              return (\n                <Badge key={v} variant=\"secondary\">\n                  {item?.label}\n                  <button\n                    className=\"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n                    onKeyDown={(e) => {\n                      if (e.key === 'Enter') {\n                        handleSelect(v);\n                      }\n                    }}\n                    onMouseDown={(e) => {\n                      e.preventDefault();\n                      e.stopPropagation();\n                    }}\n                    onClick={() => handleSelect(v)}\n                  >\n                    <X className=\"h-3 w-3 text-muted-foreground hover:text-foreground\" />\n                  </button>\n                </Badge>\n              );\n            })}\n            <CommandPrimitive.Input\n              ref={inputRef}\n              value={inputValue}\n              onValueChange={setInputValue}\n              onBlur={() => setOpen(false)}\n              onFocus={() => setOpen(true)}\n              placeholder={\n                value.length > 0\n                  ? ''\n                  : placeholder ||\n                    t('components.MultiSelectInput.placeholder', 'Select...')\n              }\n              className=\"ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground\"\n              disabled={disabled}\n            />\n          </div>\n        </div>\n        <div className=\"relative mt-2\">\n          {open && selectables.length > 0 ? (\n            <div className=\"absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in\">\n              <CommandList>\n                <CommandGroup>\n                  {selectables.map((item) => (\n                    <CommandItem\n                      key={item.value}\n                      onMouseDown={(e) => {\n                        e.preventDefault();\n                        e.stopPropagation();\n                      }}\n                      onSelect={() => {\n                        handleSelect(item.value);\n                        setInputValue('');\n                      }}\n                      className={'cursor-pointer'}\n                    >\n                      {item.label}\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </CommandList>\n            </div>\n          ) : null}\n        </div>\n      </Command>\n    </InputStateHandler>\n  );\n};\n\nexport { MultiSelectInput };\nexport type { SelectItemType, MultiSelectInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/NotificationCount.tsx",
    "content": "import React from 'react';\n\nimport { Input } from '@/components/ui/input';\n\nexport interface NotificationCountProps {\n  count: number;\n  inputProps?: {\n    id: string;\n    max?: number;\n    min?: number;\n    step?: number;\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n  };\n}\n\nconst NotificationCount = ({ count, inputProps }: NotificationCountProps) => {\n  if (!count) return null;\n\n  const renderBox = () => (\n    <div className=\"flex items-center rounded-md bg-muted px-2 py-1\">\n      <span className=\"text-xs font-semibold text-muted-foreground\">\n        {count}\n      </span>\n    </div>\n  );\n\n  const renderInput = () => {\n    const getInputWidth = (hasArrow?: boolean) => {\n      const countString = count.toString();\n      let contentWidth = countString.length * 8 + (hasArrow ? 22 : 0);\n      if (countString.includes('.') || countString.includes(',')) {\n        contentWidth -= 6;\n      }\n      return contentWidth;\n    };\n\n    return inputProps ? (\n      <Input\n        id={inputProps.id}\n        type=\"number\"\n        max={inputProps.max}\n        min={inputProps.min}\n        step={inputProps.step || 1}\n        value={count}\n        onChange={inputProps.onChange}\n        className=\"rounded-md bg-muted text-xs font-semibold text-muted-foreground\"\n        style={{\n          width: `${getInputWidth()}px`,\n          padding: '0.5rem 1rem',\n          border: 'none'\n        }}\n      />\n    ) : null;\n  };\n\n  return !inputProps ? renderBox() : renderInput();\n};\n\nexport { NotificationCount };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/RadioButtonGroup.tsx",
    "content": "import { IInput } from '@/types';\nimport * as React from 'react';\n\nimport { Label } from '@/components/ui/label';\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface RadioItemType {\n  label: string;\n  value: string;\n}\n\ninterface RadioButtonGroupProps extends IInput {\n  items?: RadioItemType[];\n  value?: string;\n  onChange: (value: string) => void;\n  setField?: (field: string, value: string, shouldValidate?: boolean) => void;\n}\n\nconst RadioButtonGroup = ({\n  id,\n  hasError,\n  description,\n  label,\n  tooltip,\n  items = [],\n  value,\n  onChange,\n  setField\n}: RadioButtonGroupProps): JSX.Element => {\n  return (\n    <InputStateHandler\n      id={id}\n      hasError={hasError}\n      description={description}\n      label={label}\n      tooltip={tooltip}\n    >\n      <RadioGroup\n        value={value}\n        onValueChange={(v: string) => {\n          onChange(v);\n          setField?.(id, v);\n        }}\n      >\n        {items.map((item) => (\n          <div key={item.value} className=\"flex items-center space-x-2\">\n            <RadioGroupItem value={item.value} id={item.value} />\n            <Label htmlFor={item.value}>{item.label}</Label>\n          </div>\n        ))}\n      </RadioGroup>\n    </InputStateHandler>\n  );\n};\n\nexport { RadioButtonGroup };\nexport type { RadioButtonGroupProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/SelectInput.tsx",
    "content": "import { IInput } from '@/types';\nimport * as React from 'react';\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@/components/ui/select';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface SelectItemType {\n  label: string;\n  icon?: React.ReactNode;\n  notificationCount?: number;\n  value: string | number;\n}\n\ninterface SelectInputProps extends IInput {\n  items?: SelectItemType[];\n  value?: string | number;\n  onChange: (value: string) => void;\n  setField?: (field: string, value: string, shouldValidate?: boolean) => void;\n  placeholder?: string;\n}\n\nconst SelectInput = ({\n  id,\n  hasError,\n  description,\n  label,\n  tooltip,\n  disabled = false,\n  items = [],\n  value,\n  onChange,\n  setField,\n  placeholder = 'Select',\n  className\n}: SelectInputProps) => {\n  return (\n    <InputStateHandler\n      id={id}\n      hasError={hasError}\n      description={description}\n      label={label}\n      tooltip={tooltip}\n    >\n      <Select\n        disabled={disabled}\n        value={value?.toString()}\n        onValueChange={(v) => {\n          onChange(v);\n          setField?.(id, v);\n        }}\n      >\n        <SelectTrigger id={id} className={className}>\n          <SelectValue placeholder={placeholder} />\n        </SelectTrigger>\n        <SelectContent>\n          {items.map((item) => (\n            <SelectItem key={item.value} value={item.value.toString()}>\n              <div className=\"flex items-center gap-2\">\n                {item.icon}\n                <span>{item.label}</span>\n                {item.notificationCount && (\n                  <span className=\"ml-auto bg-muted rounded-full px-2 py-0.5 text-xs\">\n                    {item.notificationCount}\n                  </span>\n                )}\n              </div>\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    </InputStateHandler>\n  );\n};\n\nexport { SelectInput };\nexport type { SelectItemType, SelectInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/SliderInput.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nimport { Slider } from '@/components/ui/slider';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface IInput {\n  description?: string;\n  hasError?: boolean;\n  id: string;\n  label?: string;\n  tooltip?: string;\n}\n\ninterface SliderInputProps extends IInput {\n  value?: number;\n  min?: number;\n  max?: number;\n  step?: number;\n  defaultValue?: number[];\n  disabled?: boolean;\n  onValueChange?: (value: number[]) => void;\n  setField?: (field: string, value: number, shouldValidate?: boolean) => void;\n  className?: string;\n}\n\nconst SliderInput = ({\n  description,\n  hasError,\n  id,\n  label,\n  tooltip,\n  value,\n  min = 0,\n  max = 100,\n  step = 1,\n  defaultValue = [0],\n  disabled,\n  onValueChange,\n  setField,\n  className,\n  ...props\n}: SliderInputProps) => {\n  const handleValueChange = (newValue: number[]) => {\n    const parsedValue = newValue[0];\n\n    if (max && parsedValue > max) {\n      setField?.(id, max);\n    } else if (min && parsedValue < min) {\n      setField?.(id, min);\n    } else {\n      onValueChange?.(newValue);\n      setField?.(id, parsedValue);\n    }\n  };\n\n  return (\n    <InputStateHandler\n      description={description}\n      hasError={hasError}\n      id={id}\n      label={label}\n      tooltip={tooltip}\n      notificationsProps={{\n        showBadge: true,\n        count: value || 0\n      }}\n    >\n      <Slider\n        id={id}\n        name={id}\n        max={max}\n        min={min}\n        step={step}\n        disabled={disabled}\n        value={value !== undefined ? [value] : defaultValue}\n        onValueChange={handleValueChange}\n        className={cn('w-full', className)}\n        {...props}\n      />\n    </InputStateHandler>\n  );\n};\n\nexport { SliderInput };\nexport type { SliderInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/SwitchInput.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as React from 'react';\n\nimport { Switch } from '@/components/ui/switch';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface InputStateProps {\n  description?: string;\n  hasError?: boolean;\n  id: string;\n  label?: string;\n  tooltip?: string;\n  children: React.ReactNode;\n}\n\ninterface SwitchInputProps extends InputStateProps {\n  checked: boolean;\n  disabled?: boolean;\n  onChange: (checked: boolean) => void;\n  setField?: (field: string, value: boolean, shouldValidate?: boolean) => void;\n}\n\nconst SwitchInput = ({\n  checked,\n  description,\n  disabled,\n  hasError,\n  id,\n  label,\n  setField,\n  tooltip\n}: SwitchInputProps): JSX.Element => {\n  return (\n    <InputStateHandler\n      description={description}\n      hasError={hasError}\n      id={id}\n      label={label}\n      tooltip={tooltip}\n    >\n      <Switch\n        id={id}\n        checked={checked}\n        disabled={disabled}\n        onCheckedChange={(checked) => {\n          setField?.(id, checked);\n        }}\n        className={cn(\n          'data-[state=checked]:bg-primary',\n          hasError && 'border-destructive'\n        )}\n      />\n    </InputStateHandler>\n  );\n};\n\nexport { SwitchInput };\nexport type { SwitchInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/TagsInput.tsx",
    "content": "import { X } from 'lucide-react';\nimport React from 'react';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\n\nimport { IInput } from 'types/Input';\n\nimport { InputStateHandler } from './InputStateHandler';\n\nexport type TagsInputProps = {\n  placeholder?: string;\n  value?: string[];\n  setField?(field: string, value: string[], shouldValidate?: boolean): void;\n} & IInput &\n  Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'color'>;\n\nexport const TagsInput = ({\n  description,\n  disabled,\n  hasError,\n  id,\n  label,\n  tooltip,\n  value = [],\n  setField,\n  placeholder,\n  ...rest\n}: TagsInputProps): JSX.Element => {\n  const [inputValue, setInputValue] = React.useState('');\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === 'Enter' && inputValue.trim()) {\n      e.preventDefault();\n      if (!value.includes(inputValue.trim())) {\n        const newTags = [...value, inputValue.trim()];\n        setField?.(id, newTags, false);\n      }\n      setInputValue('');\n    }\n  };\n\n  const removeTag = (tagToRemove: string) => {\n    const newTags = value.filter((tag) => tag !== tagToRemove);\n    setField?.(id, newTags, false);\n  };\n\n  return (\n    <InputStateHandler\n      description={description}\n      hasError={hasError}\n      id={id}\n      label={label}\n      tooltip={tooltip}\n    >\n      <div className=\"space-y-2\">\n        <div className=\"flex flex-wrap gap-2\">\n          {value.map((tag) => (\n            <Badge\n              key={tag}\n              variant=\"secondary\"\n              className=\"flex items-center gap-1\"\n            >\n              {tag}\n              <X\n                className=\"h-3 w-3 cursor-pointer\"\n                onClick={() => !disabled && removeTag(tag)}\n              />\n            </Badge>\n          ))}\n        </div>\n        <Input\n          {...rest}\n          id={id}\n          name={id}\n          disabled={disabled}\n          value={inputValue}\n          onChange={(e) => setInputValue(e.target.value)}\n          onKeyDown={handleKeyDown}\n          placeholder={placeholder}\n          className=\"mt-1\"\n        />\n      </div>\n    </InputStateHandler>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/TextInput.tsx",
    "content": "import { Input } from '@/components/ui/input';\nimport { Textarea } from '@/components/ui/textarea';\n\nimport { IInput } from 'types/Input';\n\nimport { InputStateHandler } from './InputStateHandler';\n\ninterface TextInputProps\n  extends IInput,\n    Omit<React.InputHTMLAttributes<any>, 'id' | 'size'> {\n  setField?: (field: string, value: string, shouldValidate?: boolean) => void;\n  value?: string;\n  placeholder?: string;\n  multiline?: boolean;\n}\n\nconst TextInput = ({\n  description,\n  disabled,\n  hasError,\n  id,\n  label,\n  tooltip,\n  multiline,\n  className,\n  setField,\n  ...rest\n}: TextInputProps): JSX.Element => {\n  const InputComponent = multiline ? Textarea : Input;\n\n  return (\n    <InputStateHandler\n      description={description}\n      hasError={hasError}\n      id={id}\n      label={label}\n      tooltip={tooltip}\n    >\n      <InputComponent\n        disabled={disabled}\n        id={id}\n        name={id}\n        {...rest}\n        onChange={(e) => setField?.(id, e.target.value)}\n        className={`text-sm font-normal my-0.5 ${className ?? ''}`}\n      />\n    </InputStateHandler>\n  );\n};\n\nexport { TextInput };\nexport type { TextInputProps };\n"
  },
  {
    "path": "frontend/src/components/ChatSettings/index.tsx",
    "content": "import isEqual from 'lodash/isEqual';\nimport mapValues from 'lodash/mapValues';\nimport { useEffect, useRef } from 'react';\nimport { useForm } from 'react-hook-form';\nimport { useRecoilState, useSetRecoilState } from 'recoil';\n\nimport {\n  chatSettingsValueState,\n  useChatData,\n  useChatInteract\n} from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/dialog';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Translator } from 'components/i18n';\n\nimport { chatSettingsOpenState } from 'state/project';\n\nimport { FormInput, TFormInputValue } from './FormInput';\n\nexport default function ChatSettingsModal() {\n  const { chatSettingsValue, chatSettingsInputs, chatSettingsDefaultValue } =\n    useChatData();\n\n  const { updateChatSettings, editChatSettings } = useChatInteract();\n  const [chatSettingsOpen, setChatSettingsOpen] = useRecoilState(\n    chatSettingsOpenState\n  );\n\n  const { handleSubmit, setValue, reset, watch } = useForm({\n    defaultValues: chatSettingsValue\n  });\n  const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);\n\n  // Reset form when default values change\n  useEffect(() => {\n    reset(chatSettingsValue);\n  }, [chatSettingsValue, reset]);\n\n  const handleClose = (open: boolean) => {\n    if (!open) {\n      reset(chatSettingsValue);\n      setChatSettingsOpen(false);\n    }\n  };\n\n  const handleConfirm = handleSubmit((data) => {\n    const processedValues = mapValues(data, (x: TFormInputValue) =>\n      x !== '' ? x : null\n    );\n    updateChatSettings(processedValues);\n    setChatSettingsValue(processedValues);\n    setChatSettingsOpen(false);\n  });\n\n  const handleReset = () => {\n    reset(chatSettingsDefaultValue);\n  };\n\n  // Legacy setField compatibility layer\n  const handleChange = () => {};\n\n  const setFieldValue = (field: string, value: any) => {\n    setValue(field, value);\n  };\n\n  const values = watch();\n  const prevValues = useRef(values);\n\n  useEffect(() => {\n    if (!isEqual(values, prevValues.current)) {\n      editChatSettings(values);\n      prevValues.current = values;\n    }\n  }, [values, editChatSettings]);\n\n  const tabInputs = chatSettingsInputs.filter(\n    (input: any) => Array.isArray(input?.inputs) && input.inputs.length > 0\n  );\n  const regularInputs = chatSettingsInputs.filter(\n    (input: any) => !Array.isArray(input?.inputs) || input.inputs.length === 0\n  );\n  const hasTabs = tabInputs.length > 0;\n  const defaultTab = tabInputs[0]?.id;\n\n  return (\n    <Dialog open={chatSettingsOpen} onOpenChange={handleClose}>\n      <DialogContent\n        id=\"chat-settings\"\n        className={`flex flex-col gap-6 p-6 ${\n          hasTabs ? 'min-w-[25vw] h-[85vh]' : 'min-w-[20vw] max-h-[85vh]'\n        }`}\n      >\n        <DialogHeader>\n          <DialogTitle>\n            <Translator path=\"chat.settings.title\" />\n          </DialogTitle>\n          <DialogDescription className=\"sr-only\">\n            <Translator path=\"chat.settings.customize\" />\n          </DialogDescription>\n        </DialogHeader>\n        {hasTabs ? (\n          <Tabs\n            defaultValue={defaultTab}\n            className=\"flex flex-col flex-grow min-h-0\"\n          >\n            <TabsList className=\"w-full flex justify-start\">\n              {tabInputs.map((tab: any) => (\n                <TabsTrigger key={tab.id} value={tab.id}>\n                  {tab.label ?? tab.id}\n                </TabsTrigger>\n              ))}\n            </TabsList>\n            {tabInputs.map((tab: any) => (\n              <TabsContent\n                key={tab.id}\n                value={tab.id}\n                className=\"data-[state=active]:flex flex-col flex-grow overflow-y-auto gap-6 p-1 mt-4\"\n              >\n                {tab.inputs?.map((input: any) => (\n                  <FormInput\n                    key={input.id}\n                    element={{\n                      ...input,\n                      value: values[input.id],\n                      onChange: handleChange,\n                      setField: setFieldValue\n                    }}\n                  />\n                ))}\n              </TabsContent>\n            ))}\n          </Tabs>\n        ) : (\n          <div className=\"flex flex-col flex-grow overflow-y-auto gap-6 p-1\">\n            {regularInputs.map((input: any) => (\n              <FormInput\n                key={input.id}\n                element={{\n                  ...input,\n                  value: values[input.id],\n                  onChange: handleChange,\n                  setField: setFieldValue\n                }}\n              />\n            ))}\n          </div>\n        )}\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={handleReset}>\n            <Translator path=\"common.actions.reset\" />\n          </Button>\n          <div className=\"flex-1\" />\n          <Button variant=\"ghost\" onClick={() => handleClose(false)}>\n            <Translator path=\"common.actions.cancel\" />\n          </Button>\n          <Button onClick={handleConfirm} id=\"confirm\" autoFocus>\n            <Translator path=\"common.actions.confirm\" />\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CodeSnippet.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport hljs from 'highlight.js';\nimport { useEffect, useRef } from 'react';\n\nimport { Card, CardContent, CardHeader } from '@/components/ui/card';\n\nimport 'highlight.js/styles/monokai-sublime.css';\n\nimport CopyButton from './CopyButton';\n\ninterface CodeSnippetProps {\n  language: string;\n  children: string;\n}\n\nconst HighlightedCode = ({ language, children }: CodeSnippetProps) => {\n  const codeRef = useRef<HTMLElement>(null);\n\n  if (!hljs.getLanguage(language)) {\n    language = 'txt';\n  }\n\n  useEffect(() => {\n    if (codeRef.current) {\n      const highlighted =\n        codeRef.current.getAttribute('data-highlighted') === 'yes';\n      if (!highlighted) {\n        hljs.highlightElement(codeRef.current);\n      }\n    }\n  }, []);\n\n  return (\n    <pre className=\"m-0\">\n      <code\n        ref={codeRef}\n        className={`language-${language} font-mono text-sm rounded-b-md block`}\n      >\n        {children}\n      </code>\n    </pre>\n  );\n};\n\ninterface CodeProps {\n  children: React.ReactNode;\n  node?: {\n    children?: Array<{\n      properties?: {\n        className?: string[];\n      };\n      children?: Array<{\n        value?: string;\n      }>;\n    }>;\n  };\n}\n\nexport default function CodeSnippet({ ...props }: CodeProps) {\n  const codeChildren = props.node?.children?.[0];\n  const className = codeChildren?.properties?.className?.[0];\n  const match = /language-(\\w+)/.exec(className || '');\n  const code = codeChildren?.children?.[0]?.value;\n\n  const showSyntaxHighlighter = match && code;\n\n  const highlightedCode = showSyntaxHighlighter ? (\n    <HighlightedCode language={match[1]}>{code}</HighlightedCode>\n  ) : null;\n\n  const nonHighlightedCode = showSyntaxHighlighter ? null : (\n    <div\n      className={cn('rounded-b-md overflow-x-auto bg-accent', code && 'p-2')}\n    >\n      <code className=\"whitespace-pre-wrap\">{code}</code>\n    </div>\n  );\n\n  return (\n    <Card className=\"relative my-2\">\n      <CardHeader className=\"flex flex-row items-center justify-between py-1 px-4\">\n        <span className=\"text-sm text-muted-foreground\">\n          {match?.[1] || 'Raw code'}\n        </span>\n        <CopyButton content={code} />\n      </CardHeader>\n      <CardContent className=\"p-0\">\n        {highlightedCode}\n        {nonHighlightedCode}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CopyButton.tsx",
    "content": "import { Check, Copy } from 'lucide-react';\nimport { useState } from 'react';\nimport { toast } from 'sonner';\n\nimport { useTranslation } from '@/components/i18n/Translator';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface Props {\n  content: unknown;\n  className?: string;\n  contentRef?: React.RefObject<HTMLDivElement>;\n}\n\nconst CopyButton = ({ content, className, contentRef }: Props) => {\n  const [copied, setCopied] = useState(false);\n  const { t } = useTranslation();\n\n  const copyToClipboard = async () => {\n    try {\n      const textToCopy =\n        typeof content === 'object'\n          ? JSON.stringify(content, null, 2)\n          : String(content);\n\n      // Create clipboard items array\n      const clipboardItems: ClipboardItem[] = [];\n\n      // Always add text version\n      clipboardItems.push(\n        new ClipboardItem({\n          'text/plain': new Blob([textToCopy], { type: 'text/plain' })\n        })\n      );\n\n      // If contentRef is provided, also add HTML version\n      if (contentRef?.current) {\n        const htmlContent = contentRef.current.innerHTML;\n        clipboardItems.push(\n          new ClipboardItem({\n            'text/html': new Blob([htmlContent], { type: 'text/html' })\n          })\n        );\n      }\n\n      // Try to write multiple formats to clipboard\n      if (navigator.clipboard.write && clipboardItems.length > 1) {\n        // Use the newer clipboard API that supports multiple formats\n        await navigator.clipboard.write([\n          new ClipboardItem({\n            'text/plain': new Blob([textToCopy], { type: 'text/plain' }),\n            ...(contentRef?.current && {\n              'text/html': new Blob([contentRef.current.innerHTML], {\n                type: 'text/html'\n              })\n            })\n          })\n        ]);\n      } else {\n        // Fallback to text-only for older browsers\n        await navigator.clipboard.writeText(textToCopy);\n      }\n\n      setCopied(true);\n\n      // Reset copied state after 2 seconds\n      setTimeout(() => {\n        setCopied(false);\n      }, 2000);\n    } catch (err) {\n      toast.error('Failed to copy: ' + String(err));\n    }\n  };\n\n  return (\n    <TooltipProvider delayDuration={100}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            onClick={copyToClipboard}\n            variant=\"ghost\"\n            size=\"icon\"\n            className={`text-muted-foreground ${className}`}\n          >\n            {copied ? (\n              <Check className=\"h-4 w-4\" />\n            ) : (\n              <Copy className=\"h-4 w-4\" />\n            )}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            {copied\n              ? t('chat.messages.actions.copy.success')\n              : t('chat.messages.actions.copy.button')}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nexport default CopyButton;\n"
  },
  {
    "path": "frontend/src/components/ElementSideView.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { ArrowLeft } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { useRecoilState } from 'recoil';\n\nimport { sideViewState } from '@chainlit/react-client';\n\nimport { Card, CardContent } from '@/components/ui/card';\nimport { ResizableHandle, ResizablePanel } from '@/components/ui/resizable';\nimport {\n  Sheet,\n  SheetContent,\n  SheetHeader,\n  SheetTitle\n} from '@/components/ui/sheet';\n\nimport { useIsMobile } from '@/hooks/use-mobile';\n\nimport { Element } from './Elements';\nimport { Button } from './ui/button';\n\nexport default function ElementSideView() {\n  const [sideView, setSideView] = useRecoilState(sideViewState);\n  const isMobile = useIsMobile();\n  const [isVisible, setIsVisible] = useState(false);\n\n  const isCanvas = sideView?.title === 'canvas';\n\n  useEffect(() => {\n    if (sideView) {\n      // Delay setting visibility to trigger animation\n      requestAnimationFrame(() => {\n        setIsVisible(true);\n      });\n    } else {\n      setIsVisible(false);\n    }\n  }, [sideView]);\n\n  if (!sideView) return null;\n\n  if (isMobile) {\n    return (\n      <Sheet open onOpenChange={(open) => !open && setSideView(undefined)}>\n        <SheetContent\n          className={cn('md:hidden flex flex-col', isCanvas && 'p-0')}\n        >\n          {!isCanvas ? (\n            <SheetHeader>\n              <SheetTitle id=\"side-view-title\">{sideView.title}</SheetTitle>\n            </SheetHeader>\n          ) : null}\n          <div\n            id=\"side-view-content\"\n            className={cn(\n              'overflow-y-auto flex-grow flex flex-grow flex-col',\n              isCanvas ? 'p-0' : 'gap-4 mt-4'\n            )}\n          >\n            {sideView.elements.map((e) => (\n              <Element key={e.id} element={e} />\n            ))}\n          </div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n\n  return (\n    <>\n      <ResizableHandle className=\"sm:hidden md:block bg-transparent\" />\n      <ResizablePanel\n        minSize={isCanvas ? 30 : 10}\n        defaultSize={isCanvas ? 50 : 20}\n        className={`md:flex flex-col flex-grow sm:hidden transform transition-transform duration-300 ease-in-out ${\n          isVisible ? 'translate-x-0' : 'translate-x-full'\n        }`}\n      >\n        <aside className=\"relative flex-grow overflow-y-auto mr-4 mb-4\">\n          <Card className=\"overflow-y-auto h-full relative flex flex-col\">\n            <div\n              id=\"side-view-title\"\n              className={cn(\n                'text-lg font-semibold text-foreground px-6 py-4 flex items-center',\n                isCanvas && 'absolute top-0 z-10 bg-transparent'\n              )}\n            >\n              <Button\n                className=\"-ml-2\"\n                onClick={() => setSideView(undefined)}\n                size=\"icon\"\n                variant={isCanvas ? 'default' : 'ghost'}\n              >\n                <ArrowLeft />\n              </Button>\n              {isCanvas ? null : sideView.title}\n            </div>\n            <CardContent\n              id=\"side-view-content\"\n              className={cn(\n                'flex flex-col flex-grow',\n                isCanvas ? 'p-0' : 'gap-4'\n              )}\n            >\n              {sideView.elements.map((e) => (\n                <Element key={e.id} element={e} />\n              ))}\n            </CardContent>\n          </Card>\n        </aside>\n      </ResizablePanel>\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ElementView.tsx",
    "content": "import { ArrowLeft } from 'lucide-react';\n\nimport type { IMessageElement } from '@chainlit/react-client';\n\nimport { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';\n\nimport { Element } from './Elements';\nimport { Button } from './ui/button';\n\ninterface ElementViewProps {\n  element: IMessageElement;\n  onGoBack?: () => void;\n}\n\nconst ElementView = ({ element, onGoBack }: ElementViewProps) => {\n  const layoutMaxWidth = useLayoutMaxWidth();\n\n  return (\n    <div\n      className=\"flex flex-col flex-grow p-4 mx-auto gap-4 w-full\"\n      style={{\n        maxWidth: layoutMaxWidth\n      }}\n      id=\"element-view\"\n    >\n      <div className=\"flex items-center gap-1 -ml-2\">\n        {onGoBack ? (\n          <Button size=\"icon\" variant=\"ghost\" onClick={onGoBack}>\n            <ArrowLeft />\n          </Button>\n        ) : null}\n        <div className=\"text-lg font-semibold leading-none tracking-tight\">\n          {element.name}\n        </div>\n      </div>\n\n      <Element element={element} />\n    </div>\n  );\n};\n\nexport { ElementView };\n"
  },
  {
    "path": "frontend/src/components/Elements/Audio.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nimport { IAudioElement } from '@chainlit/react-client';\n\nconst AudioElement = ({ element }: { element: IAudioElement }) => {\n  if (!element.url) {\n    return null;\n  }\n\n  return (\n    <div className={cn('space-y-2', `${element.display}-audio`)}>\n      <p className=\"text-sm leading-7 text-muted-foreground\">{element.name}</p>\n      <audio controls src={element.url} autoPlay={element.autoPlay} />\n    </div>\n  );\n};\n\nexport { AudioElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/CustomElement/Imports.ts",
    "content": "import * as LucideIcons from 'lucide-react';\nimport React from 'react';\nimport * as ReactHookForm from 'react-hook-form';\nimport * as Recoil from 'recoil';\nimport * as Sonner from 'sonner';\nimport * as Zod from 'zod';\n\nimport * as ChainlitReactClient from '@chainlit/react-client';\n\nimport * as Markdown from '@/components/Markdown';\nimport * as AccordionComponents from '@/components/ui/accordion';\nimport * as AspectRatioComponents from '@/components/ui/aspect-ratio';\nimport * as AvatarComponents from '@/components/ui/avatar';\nimport * as BadgeComponents from '@/components/ui/badge';\nimport * as ButtonComponents from '@/components/ui/button';\nimport * as CardComponents from '@/components/ui/card';\nimport * as CarouselComponents from '@/components/ui/carousel';\nimport * as CheckboxComponents from '@/components/ui/checkbox';\nimport * as CommandComponents from '@/components/ui/command';\nimport * as DialogComponents from '@/components/ui/dialog';\nimport * as DropdownMenuComponents from '@/components/ui/dropdown-menu';\nimport * as FormComponents from '@/components/ui/form';\nimport * as HoverCardComponents from '@/components/ui/hover-card';\nimport * as InputComponents from '@/components/ui/input';\nimport * as LabelComponents from '@/components/ui/label';\nimport * as PaginationComponents from '@/components/ui/pagination';\nimport * as PopoverComponents from '@/components/ui/popover';\nimport * as ProgressComponents from '@/components/ui/progress';\nimport * as ScrollAreaComponents from '@/components/ui/scroll-area';\nimport * as SelectComponents from '@/components/ui/select';\nimport * as SeparatorComponents from '@/components/ui/separator';\nimport * as SheetComponents from '@/components/ui/sheet';\nimport * as SkeletonComponents from '@/components/ui/skeleton';\nimport * as SwitchComponents from '@/components/ui/switch';\nimport * as TableComponents from '@/components/ui/table';\nimport * as TabsComponents from '@/components/ui/tabs';\nimport * as TextareaComponents from '@/components/ui/textarea';\nimport * as TooltipComponents from '@/components/ui/tooltip';\n\nconst Imports = {\n  react: React,\n  sonner: Sonner,\n  zod: Zod,\n  recoil: Recoil,\n  '@chainlit/react-client': ChainlitReactClient,\n  '@/components/markdown': Markdown,\n  'react-hook-form': ReactHookForm,\n  'lucide-react': LucideIcons,\n  '@/components/ui/tabs': TabsComponents,\n  '@/components/ui/accordion': AccordionComponents,\n  '@/components/ui/aspect-ratio': AspectRatioComponents,\n  '@/components/ui/avatar': AvatarComponents,\n  '@/components/ui/badge': BadgeComponents,\n  '@/components/ui/button': ButtonComponents,\n  '@/components/ui/card': CardComponents,\n  '@/components/ui/carousel': CarouselComponents,\n  '@/components/ui/checkbox': CheckboxComponents,\n  '@/components/ui/command': CommandComponents,\n  '@/components/ui/dialog': DialogComponents,\n  '@/components/ui/dropdown-menu': DropdownMenuComponents,\n  '@/components/ui/form': FormComponents,\n  '@/components/ui/hover-card': HoverCardComponents,\n  '@/components/ui/input': InputComponents,\n  '@/components/ui/label': LabelComponents,\n  '@/components/ui/pagination': PaginationComponents,\n  '@/components/ui/popover': PopoverComponents,\n  '@/components/ui/progress': ProgressComponents,\n  '@/components/ui/scroll-area': ScrollAreaComponents,\n  '@/components/ui/separator': SeparatorComponents,\n  '@/components/ui/select': SelectComponents,\n  '@/components/ui/sheet': SheetComponents,\n  '@/components/ui/skeleton': SkeletonComponents,\n  '@/components/ui/switch': SwitchComponents,\n  '@/components/ui/table': TableComponents,\n  '@/components/ui/textarea': TextareaComponents,\n  '@/components/ui/tooltip': TooltipComponents\n};\n\nexport default Imports;\n"
  },
  {
    "path": "frontend/src/components/Elements/CustomElement/Renderer.tsx",
    "content": "import { memo, useState } from 'react';\nimport { Runner } from 'react-runner';\n\nimport Alert from '@/components/Alert';\n\nimport Imports from './Imports';\n\nconst createMockAPIs = () => {\n  return {\n    updateElement: async (\n      nextProps: Record<string, any>\n    ): Promise<{ success: boolean }> => {\n      console.log('updateElement called with:', nextProps);\n      return { success: true };\n    },\n\n    deleteElement: async (): Promise<{ success: boolean }> => {\n      console.log('deleteElement called');\n      return { success: true };\n    },\n\n    callAction: async (action: {\n      name: string;\n      payload: Record<string, unknown>;\n    }): Promise<{ success: boolean }> => {\n      console.log('callAction called with:', action);\n      return { success: true };\n    },\n\n    sendUserMessage: (message: string, command?: string): void => {\n      console.log('sendUserMessage called with:', message, command);\n    }\n  };\n};\n\nconst Renderer = memo(function ({\n  sourceCode,\n  props\n}: {\n  sourceCode: string;\n  props: Record<string, unknown>;\n}) {\n  const [error, setError] = useState<string>();\n\n  if (error) return <Alert variant=\"error\">{error}</Alert>;\n\n  if (!sourceCode) return null;\n\n  const mockedApis = createMockAPIs();\n\n  return (\n    <Runner\n      code={sourceCode}\n      scope={{\n        import: Imports,\n        props,\n        ...mockedApis\n      }}\n      onRendered={(error) => setError(error?.message)}\n    />\n  );\n});\n\nexport { Renderer };\n"
  },
  {
    "path": "frontend/src/components/Elements/CustomElement/index.tsx",
    "content": "import { MessageContext } from 'contexts/MessageContext';\nimport {\n  memo,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState\n} from 'react';\nimport { Runner } from 'react-runner';\nimport { useRecoilValue } from 'recoil';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport {\n  ChainlitContext,\n  IAction,\n  ICustomElement,\n  IElement,\n  sessionIdState,\n  useAuth,\n  useChatInteract\n} from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\n\nimport Imports from './Imports';\nimport * as Renderer from './Renderer';\n\nconst CustomElement = memo(function ({ element }: { element: ICustomElement }) {\n  const apiClient = useContext(ChainlitContext);\n  const sessionId = useRecoilValue(sessionIdState);\n  const { sendMessage } = useChatInteract();\n  const { user } = useAuth();\n  const { askUser } = useContext(MessageContext);\n\n  const [sourceCode, setSourceCode] = useState<string>();\n  const [error, setError] = useState<string>();\n\n  useEffect(() => {\n    apiClient\n      .get(`/public/elements/${element.name}.jsx`)\n      .then(async (res) => setSourceCode(await res.text()))\n      .catch((err) => setError(String(err)));\n  }, [element.name, apiClient]);\n\n  const updateElement = useCallback(\n    (nextProps: Record<string, unknown>) => {\n      if (!sessionId) return;\n      const nextElement: IElement = { ...element, props: nextProps };\n      return apiClient.updateElement(nextElement, sessionId);\n    },\n    [element, sessionId, apiClient]\n  );\n\n  const deleteElement = useCallback(() => {\n    if (!sessionId) return;\n    return apiClient.deleteElement(element, sessionId);\n  }, [element, sessionId, apiClient]);\n\n  const callAction = useCallback(\n    (action: IAction) => {\n      if (!sessionId) return;\n      return apiClient.callAction(action, sessionId);\n    },\n    [sessionId, apiClient]\n  );\n\n  const sendUserMessage = useCallback(\n    (message: string, command?: string) => {\n      return sendMessage({\n        threadId: '',\n        id: uuidv4(),\n        name: user?.identifier || 'User',\n        type: 'user_message',\n        output: message,\n        createdAt: new Date().toISOString(),\n        metadata: { location: window.location.href },\n        command\n      });\n    },\n    [sendMessage, user]\n  );\n\n  const submitElement = useCallback(\n    (props: Record<string, unknown>) => {\n      if (\n        askUser?.spec.type === 'element' &&\n        askUser.spec.step_id === element.forId\n      ) {\n        askUser.callback({ ...props, submitted: true });\n      }\n    },\n    [askUser, element.forId]\n  );\n\n  const cancelElement = useCallback(() => {\n    if (\n      askUser?.spec.type === 'element' &&\n      askUser.spec.step_id === element.forId\n    ) {\n      askUser.callback({ submitted: false });\n    }\n  }, [askUser, element.forId]);\n\n  const props = useMemo(() => {\n    return JSON.parse(JSON.stringify(element.props));\n  }, [element.props]);\n\n  if (error) return <Alert variant=\"error\">{error}</Alert>;\n  if (!sourceCode) return null;\n\n  return (\n    <div className={`${element.display}-custom flex flex-col flex-grow`}>\n      <Runner\n        code={sourceCode}\n        scope={{\n          import: { ...Imports, '@/components/renderer': Renderer },\n          props,\n          apiClient,\n          updateElement,\n          deleteElement,\n          callAction,\n          sendUserMessage,\n          submitElement,\n          cancelElement\n        }}\n        onRendered={(error) => setError(error?.message)}\n      />\n    </div>\n  );\n});\n\nexport default CustomElement;\n"
  },
  {
    "path": "frontend/src/components/Elements/Dataframe.tsx",
    "content": "import {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable\n} from '@tanstack/react-table';\nimport { ArrowDown, ArrowUp } from 'lucide-react';\nimport { useCallback, useMemo } from 'react';\n\nimport { IDataframeElement } from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { Loader } from '@/components/Loader';\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious\n} from '@/components/ui/pagination';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow\n} from '@/components/ui/table';\n\nimport { useFetch } from 'hooks/useFetch';\n\ninterface DataframeData {\n  index: (string | number)[];\n  columns: string[];\n  data: (string | number)[][];\n}\n\nconst _DataframeElement = ({ data }: { data: DataframeData }) => {\n  const { index, columns, data: rowData } = data;\n\n  const tableColumns: ColumnDef<Record<string, string | number>>[] = useMemo(\n    () =>\n      columns.map((col: string) => ({\n        accessorKey: col,\n        header: ({ column }) => {\n          const sort = column.getIsSorted();\n          return (\n            <div\n              className=\"flex items-center cursor-pointer\"\n              onClick={() => column.toggleSorting()}\n            >\n              {col}\n              {sort === 'asc' && <ArrowUp className=\"ml-2 !size-3\" />}\n              {sort === 'desc' && <ArrowDown className=\"ml-2 !size-3\" />}\n            </div>\n          );\n        }\n      })),\n    [columns]\n  );\n\n  const tableRows = useMemo(\n    () =>\n      rowData.map((row, idx) => {\n        const rowObj: Record<string, string | number> = { id: index[idx] };\n        columns.forEach((col, colIdx) => {\n          rowObj[col] = row[colIdx];\n        });\n        return rowObj;\n      }),\n    [rowData, columns, index]\n  );\n\n  const table = useReactTable({\n    data: tableRows,\n    columns: tableColumns,\n    getCoreRowModel: getCoreRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    initialState: {\n      pagination: { pageSize: 10 }\n    }\n  });\n\n  const renderPaginationItems = useCallback(() => {\n    return Array.from({ length: table.getPageCount() }, (_, i) => (\n      <PaginationItem key={i}>\n        <PaginationLink\n          onClick={() => table.setPageIndex(i)}\n          isActive={table.getState().pagination.pageIndex === i}\n        >\n          {i + 1}\n        </PaginationLink>\n      </PaginationItem>\n    ));\n  }, [table.getPageCount(), table.getState().pagination.pageIndex]);\n\n  return (\n    <div className=\"flex flex-col gap-2 h-full overflow-y-auto dataframe\">\n      <div className=\"rounded-md border overflow-y-auto\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow key={row.id}>\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  No results.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <Pagination>\n        <PaginationContent className=\"ml-auto\">\n          <PaginationItem>\n            <PaginationPrevious\n              onClick={() => table.previousPage()}\n              className={\n                !table.getCanPreviousPage()\n                  ? 'pointer-events-none opacity-50'\n                  : 'cursor-pointer'\n              }\n            />\n          </PaginationItem>\n          {renderPaginationItems()}\n          <PaginationItem>\n            <PaginationNext\n              onClick={() => table.nextPage()}\n              className={\n                !table.getCanNextPage()\n                  ? 'pointer-events-none opacity-50'\n                  : 'cursor-pointer'\n              }\n            />\n          </PaginationItem>\n        </PaginationContent>\n      </Pagination>\n    </div>\n  );\n};\n\nfunction DataframeElement({ element }: { element: IDataframeElement }) {\n  const { data, isLoading, error } = useFetch(element.url || null);\n\n  const jsonData = useMemo(() => {\n    if (data) return JSON.parse(data);\n  }, [data]);\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-full w-full bg-muted\">\n        <Loader />\n      </div>\n    );\n  }\n\n  if (error) {\n    return <Alert variant=\"error\">{error.message}</Alert>;\n  }\n\n  return <_DataframeElement data={jsonData} />;\n}\n\nexport default DataframeElement;\n"
  },
  {
    "path": "frontend/src/components/Elements/ElementRef.tsx",
    "content": "import { MessageContext } from '@/contexts/MessageContext';\nimport { useContext } from 'react';\n\nimport type { IMessageElement } from '@chainlit/react-client';\n\ninterface ElementRefProps {\n  element: IMessageElement;\n}\n\nconst ElementRef = ({ element }: ElementRefProps) => {\n  const { onElementRefClick } = useContext(MessageContext);\n\n  // For inline elements, return a styled span\n  if (element.display === 'inline') {\n    return <span className=\"font-bold\">{element.name}</span>;\n  }\n\n  // For other elements, return a clickable link\n  return (\n    <a\n      href=\"#\"\n      className=\"cursor-pointer uppercase -translate-y-px inline-flex items-center rounded-xl bg-muted px-1.5 text-[0.7rem] font-medium text-muted-foreground element-link hover:bg-primary hover:text-primary-foreground\"\n      onClick={() => onElementRefClick?.(element)}\n    >\n      {element.name}\n    </a>\n  );\n};\n\nexport { ElementRef };\n"
  },
  {
    "path": "frontend/src/components/Elements/File.tsx",
    "content": "import { type IFileElement } from '@chainlit/react-client';\n\nimport { Attachment } from '@/components/chat/MessageComposer/Attachment';\n\nconst FileElement = ({ element }: { element: IFileElement }) => {\n  if (!element.url) {\n    return null;\n  }\n\n  return (\n    <a\n      className={`${element.display}-file no-underline`}\n      download={element.name}\n      href={element.url}\n      target=\"_blank\"\n    >\n      <Attachment name={element.name} mime={element.mime!} />\n    </a>\n  );\n};\n\nexport { FileElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/Image.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { X } from 'lucide-react';\nimport { useState } from 'react';\n\nimport { IImageElement } from '@chainlit/react-client';\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogOverlay,\n  DialogPortal\n} from '@/components/ui/dialog';\n\nconst ImageElement = ({ element }: { element: IImageElement }) => {\n  const [lightboxOpen, setLightboxOpen] = useState(false);\n\n  if (!element.url) return null;\n\n  const handleClick = () => {\n    setLightboxOpen(true);\n  };\n\n  return (\n    <>\n      <div className=\"rounded-sm bg-accent overflow-hidden\">\n        <img\n          className={cn(\n            'mx-auto block max-w-full h-auto',\n            element.display === 'inline' && 'cursor-pointer',\n            `${element.display}-image`\n          )}\n          src={element.url}\n          alt={element.name}\n          loading=\"lazy\"\n          onClick={handleClick}\n        />\n      </div>\n\n      <Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>\n        <DialogPortal>\n          <DialogOverlay className=\"bg-black/80\" />\n          <DialogContent className=\"border-none bg-transparent shadow-none max-w-none p-0 max-h-screen overflow-auto [&>button]:hidden\">\n            <div className=\"relative w-full h-full flex items-center justify-center\">\n              <button\n                onClick={() => setLightboxOpen(false)}\n                className=\"absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white\"\n                aria-label=\"Close lightbox\"\n              >\n                <X className=\"h-6 w-6\" />\n              </button>\n\n              <img\n                src={element.url}\n                alt={element.name}\n                className=\"max-w-[90vw] max-h-[90vh] object-contain\"\n                onClick={(e) => e.stopPropagation()}\n              />\n            </div>\n          </DialogContent>\n        </DialogPortal>\n      </Dialog>\n    </>\n  );\n};\n\nexport { ImageElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/LazyDataframe.tsx",
    "content": "import { Suspense, lazy } from 'react';\n\nimport { IDataframeElement } from '@chainlit/react-client';\n\nimport { Skeleton } from '@/components/ui/skeleton';\n\ninterface Props {\n  element: IDataframeElement;\n}\nconst DataframeElement = lazy(() => import('@/components/Elements/Dataframe'));\n\nconst LazyDataframe = ({ element }: Props) => {\n  return (\n    <Suspense fallback={<Skeleton className=\"h-full rounded-md\" />}>\n      <DataframeElement element={element} />\n    </Suspense>\n  );\n};\n\nexport { LazyDataframe };\n"
  },
  {
    "path": "frontend/src/components/Elements/PDF.tsx",
    "content": "import { type IPdfElement } from 'client-types/';\n\ninterface Props {\n  element: IPdfElement;\n}\n\nconst PDFElement = ({ element }: Props) => {\n  if (!element.url) {\n    return null;\n  }\n  const url = element.page\n    ? `${element.url}#page=${element.page}`\n    : element.url;\n  return (\n    <iframe\n      className={`${element.display}-pdf h-full w-full border-none`}\n      src={url}\n    ></iframe>\n  );\n};\n\nexport { PDFElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/Plotly.tsx",
    "content": "import { Suspense, lazy } from 'react';\n\nimport { ErrorBoundary } from '@/components/ErrorBoundary';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nimport { useFetch } from 'hooks/useFetch';\n\nimport { type IPlotlyElement } from 'client-types/';\n\nconst Plot = lazy(() => import('react-plotly.js'));\n\ninterface Props {\n  element: IPlotlyElement;\n}\n\nconst _PlotlyElement = ({ element }: Props) => {\n  const { data, error, isLoading } = useFetch(element.url || null);\n\n  if (isLoading) {\n    return <div>Loading...</div>;\n  } else if (error) {\n    return <div>An error occurred</div>;\n  }\n\n  let state;\n\n  if (data) {\n    state = data;\n  } else {\n    return null;\n  }\n\n  return (\n    <Suspense fallback={<Skeleton className=\"h-full rounded-md\" />}>\n      <Plot\n        className={`${element.display}-plotly`}\n        data={state.data}\n        layout={state.layout}\n        frames={state.frames}\n        config={state.config}\n        style={{\n          width: '100%',\n          height: '100%',\n          borderRadius: '1rem',\n          overflow: 'hidden'\n        }}\n        useResizeHandler={true}\n      />\n    </Suspense>\n  );\n};\n\nconst PlotlyElement = (props: Props) => {\n  return (\n    <ErrorBoundary prefix=\"Failed to load chart.\">\n      <_PlotlyElement {...props} />\n    </ErrorBoundary>\n  );\n};\n\nexport { PlotlyElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/Text.tsx",
    "content": "import { type ITextElement, useConfig } from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { Markdown } from '@/components/Markdown';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nimport { useFetch } from 'hooks/useFetch';\n\ninterface TextElementProps {\n  element: ITextElement;\n}\n\nconst TextElement = ({ element }: TextElementProps) => {\n  const { data, error, isLoading } = useFetch(element.url || null);\n  const { config } = useConfig();\n  const allowHtml = config?.features?.unsafe_allow_html;\n  const latex = config?.features?.latex;\n\n  let content = '';\n\n  if (isLoading) {\n    return <Skeleton className=\"h-4 w-full\" />;\n  }\n\n  if (error) {\n    return (\n      <Alert variant=\"error\">An error occurred while loading the content</Alert>\n    );\n  }\n\n  if (data) {\n    content = data;\n  }\n\n  if (element.language) {\n    content = `\\`\\`\\`${element.language}\\n${content}\\n\\`\\`\\``;\n  }\n\n  return (\n    <Markdown\n      allowHtml={allowHtml}\n      latex={latex}\n      renderMarkdown={true}\n      className={`${element.display}-text`}\n    >\n      {content}\n    </Markdown>\n  );\n};\n\nexport { TextElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/Video.tsx",
    "content": "import ReactPlayer from 'react-player';\n\nimport { type IVideoElement } from '@chainlit/react-client';\n\nconst VideoElement = ({ element }: { element: IVideoElement }) => {\n  if (!element.url) {\n    return null;\n  }\n\n  return (\n    <ReactPlayer\n      className={`${element.display}-video`}\n      width=\"100%\"\n      controls\n      url={element.url}\n      config={element.playerConfig || {}}\n    />\n  );\n};\n\nexport { VideoElement };\n"
  },
  {
    "path": "frontend/src/components/Elements/index.tsx",
    "content": "import type { IMessageElement } from '@chainlit/react-client';\n\nimport { AudioElement } from './Audio';\nimport CustomElement from './CustomElement';\nimport { FileElement } from './File';\nimport { ImageElement } from './Image';\nimport { LazyDataframe } from './LazyDataframe';\nimport { PDFElement } from './PDF';\nimport { PlotlyElement } from './Plotly';\nimport { TextElement } from './Text';\nimport { VideoElement } from './Video';\n\ninterface ElementProps {\n  element?: IMessageElement;\n}\n\nconst Element = ({ element }: ElementProps): JSX.Element | null => {\n  switch (element?.type) {\n    case 'file':\n      return <FileElement element={element} />;\n    case 'image':\n      return <ImageElement element={element} />;\n    case 'text':\n      return <TextElement element={element} />;\n    case 'pdf':\n      return <PDFElement element={element} />;\n    case 'audio':\n      return <AudioElement element={element} />;\n    case 'video':\n      return <VideoElement element={element} />;\n    case 'plotly':\n      return <PlotlyElement element={element} />;\n    case 'dataframe':\n      return <LazyDataframe element={element} />;\n    case 'custom':\n      return <CustomElement element={element} />;\n    default:\n      return null;\n  }\n};\n\nexport { Element };\n"
  },
  {
    "path": "frontend/src/components/ErrorBoundary.tsx",
    "content": "import { Component, ErrorInfo, ReactNode } from 'react';\n\nimport Alert from './Alert';\n\ninterface Props {\n  prefix?: string;\n  children?: ReactNode;\n}\n\ninterface State {\n  hasError: boolean;\n  error?: string;\n}\n\nclass ErrorBoundary extends Component<Props, State> {\n  public state: State = {\n    hasError: false,\n    error: undefined\n  };\n\n  public static getDerivedStateFromError(err: Error): State {\n    // Update state so the next render will show the fallback UI.\n    return { hasError: true, error: err.message };\n  }\n\n  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    console.error('Uncaught error:', error, errorInfo);\n  }\n\n  public render() {\n    if (this.state.hasError) {\n      const msg = this.props.prefix\n        ? `${this.props.prefix}: ${this.state.error}`\n        : this.state.error;\n      return (\n        <div className=\"flex-grow\">\n          <Alert variant=\"error\">{msg}</Alert>\n        </div>\n      );\n    }\n\n    return this.props.children;\n  }\n}\n\nexport { ErrorBoundary };\n"
  },
  {
    "path": "frontend/src/components/Icon.tsx",
    "content": "import * as LucideIcons from 'lucide-react';\n\ninterface Props {\n  name: string;\n  className?: string;\n  size?: number;\n  color?: string;\n}\n\nconst Icon = ({ name, ...props }: Props) => {\n  // Convert the name to proper case\n  const formatIconName = (name: string): string => {\n    //aggressively lowercase the parts to clean up inputs like \"ChEvRoN-rIgHt\"\n    if (name.includes('-')) {\n      return name\n        .split('-')\n        .map(\n          (part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()\n        )\n        .join('');\n    }\n    if (name === name.toUpperCase()) {\n      return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();\n    }\n    return name.charAt(0).toUpperCase() + name.slice(1);\n  };\n\n  // Try to get the icon component using the formatted name\n  const formattedName = formatIconName(name);\n  const IconComponent = LucideIcons[\n    formattedName as keyof typeof LucideIcons\n  ] as any;\n\n  if (!IconComponent) {\n    console.warn(`Icon \"${name}\" not found in Lucide icons`);\n    return null;\n  }\n\n  return <IconComponent {...props} />;\n};\n\nexport default Icon;\n"
  },
  {
    "path": "frontend/src/components/Kbd.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Slot } from '@radix-ui/react-slot';\nimport { Command, CornerDownLeft } from 'lucide-react';\nimport { ForwardedRef, forwardRef } from 'react';\n\nimport { usePlatform } from '@/hooks/usePlatform';\n\nexport type KbdProps = React.HTMLAttributes<HTMLElement> & {\n  asChild?: boolean;\n};\n\nconst Kbd = forwardRef(\n  (\n    { asChild, children, className, ...kbdProps }: KbdProps,\n    forwardedRef: ForwardedRef<HTMLElement>\n  ) => {\n    const { isMac } = usePlatform();\n    const Comp = asChild ? Slot : 'kbd';\n\n    const formatChildren = (child: React.ReactNode): React.ReactNode => {\n      if (typeof child === 'string') {\n        const lowerChild = child.toLowerCase();\n        if (lowerChild === 'enter') {\n          return <CornerDownLeft className=\"!size-4\" />;\n        }\n        if (lowerChild === 'cmd+enter' || lowerChild === 'ctrl+enter') {\n          const cmdKey = isMac ? <Command className=\"!size-4\" /> : 'Ctrl';\n          return (\n            <>\n              {cmdKey}\n              <CornerDownLeft className=\"!size-4 ml-0.5\" />\n            </>\n          );\n        }\n        return isMac\n          ? child.replace(/cmd/i, '⌘')\n          : child.replace(/cmd/i, 'Ctrl');\n      }\n      return child;\n    };\n\n    const formattedChildren = Array.isArray(children)\n      ? children.map(formatChildren)\n      : formatChildren(children);\n\n    return (\n      <Comp\n        {...kbdProps}\n        className={cn(\n          'inline-flex select-none items-center justify-center whitespace-nowrap rounded-[4px] bg-muted px-1 py-[1px] font-mono text-xs tracking-tight text-muted-foreground shadow',\n          className\n        )}\n        ref={forwardedRef}\n      >\n        {formattedChildren}\n      </Comp>\n    );\n  }\n);\nKbd.displayName = 'Kbd';\n\nexport { Kbd };\n"
  },
  {
    "path": "frontend/src/components/LeftSidebar/Search.tsx",
    "content": "import _ from 'lodash';\nimport { useContext, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router-dom';\nimport { toast } from 'sonner';\n\nimport { ChainlitContext, IThread } from '@chainlit/react-client';\n\nimport { Loader } from '@/components/Loader';\nimport { Search } from '@/components/icons/Search';\nimport { Button } from '@/components/ui/button';\nimport {\n  CommandDialog,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList\n} from '@/components/ui/command';\nimport { DialogTitle } from '@/components/ui/dialog';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\nimport { Kbd } from '../Kbd';\n\nexport default function SearchChats() {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const [open, setOpen] = useState(false);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [threads, setThreads] = useState<IThread[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const apiClient = useContext(ChainlitContext);\n\n  // Debounced search function\n  const debouncedSearch = useMemo(\n    () =>\n      _.debounce(async (query: string) => {\n        setLoading(true);\n        try {\n          const { data } = await apiClient.listThreads(\n            { first: 20, cursor: undefined },\n            { search: query || undefined }\n          );\n          setThreads(data || []);\n        } catch (error) {\n          toast.error('Error fetching threads: ' + error);\n        } finally {\n          setLoading(false);\n        }\n      }, 300),\n    [apiClient]\n  );\n\n  // Group threads by month and year\n  const groupedThreads = useMemo(() => {\n    return _.groupBy(threads, (thread) => {\n      const date = new Date(thread.createdAt);\n      return `${date.toLocaleString('default', {\n        month: 'long'\n      })} ${date.getFullYear()}`;\n    });\n  }, [threads]);\n\n  useEffect(() => {\n    const down = (e: KeyboardEvent) => {\n      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault();\n        setOpen((open) => !open);\n      }\n    };\n\n    document.addEventListener('keydown', down);\n    return () => document.removeEventListener('keydown', down);\n  }, []);\n\n  useEffect(() => {\n    debouncedSearch(searchQuery);\n    return () => {\n      debouncedSearch.cancel();\n    };\n  }, [searchQuery, debouncedSearch]);\n\n  return (\n    <>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              id=\"search-chats-button\"\n              onClick={() => setOpen(!open)}\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"text-muted-foreground hover:text-muted-foreground\"\n            >\n              <Search className=\"!size-5\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <div className=\"flex flex-col items-center\">\n              <Translator path=\"threadHistory.sidebar.filters.search\" />\n              <Kbd>Cmd+k</Kbd>\n            </div>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <CommandDialog open={open} onOpenChange={setOpen}>\n        <DialogTitle className=\"sr-only\">\n          {t('threadHistory.sidebar.filters.search')}\n        </DialogTitle>\n        <CommandInput\n          placeholder={t('threadHistory.sidebar.filters.placeholder')}\n          value={searchQuery}\n          onValueChange={setSearchQuery}\n        />\n        <CommandList className=\"h-[300px] overflow-y-auto\">\n          {loading ? (\n            <CommandEmpty className=\"p-4 flex items-center justify-center\">\n              <Loader />\n            </CommandEmpty>\n          ) : Object.keys(groupedThreads).length === 0 ? (\n            <CommandEmpty>\n              <Translator path=\"threadHistory.sidebar.empty\" />\n            </CommandEmpty>\n          ) : (\n            Object.entries(groupedThreads).map(([monthYear, monthThreads]) => (\n              <CommandGroup\n                key={`${searchQuery}-${monthYear}`}\n                heading={monthYear}\n              >\n                {monthThreads.map((thread) => (\n                  <CommandItem\n                    className=\"cursor-pointer\"\n                    key={`${searchQuery}-${thread.id}`}\n                    value={`${searchQuery}-${thread.id}`}\n                    onSelect={() => {\n                      setOpen(false);\n                      navigate(`/thread/${thread.id}`);\n                    }}\n                  >\n                    <div className=\"line-clamp-2\">\n                      {thread.name || 'Untitled Conversation'}\n                    </div>\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            ))\n          )}\n        </CommandList>\n      </CommandDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LeftSidebar/ThreadHistory.tsx",
    "content": "import { uniqBy } from 'lodash';\nimport { useContext, useEffect, useRef, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilState } from 'recoil';\n\nimport {\n  ChainlitContext,\n  threadHistoryState,\n  useChatMessages\n} from '@chainlit/react-client';\n\nimport {\n  SidebarContent,\n  SidebarGroup,\n  SidebarMenu\n} from '@/components/ui/sidebar';\n\nimport { ThreadList } from './ThreadList';\n\nconst BATCH_SIZE = 35;\nlet _scrollTop = 0;\n\nexport function ThreadHistory() {\n  const navigate = useNavigate();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const apiClient = useContext(ChainlitContext);\n  const { firstInteraction, messages, threadId } = useChatMessages();\n  const [threadHistory, setThreadHistory] = useRecoilState(threadHistoryState);\n  const [error, setError] = useState<string>();\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n  const [isFetching, setIsFetching] = useState(false);\n  const [shouldLoadMore, setShouldLoadMore] = useState(false);\n  const prevMessageCountRef = useRef(0);\n\n  // Restore scroll position\n  useEffect(() => {\n    if (scrollRef.current) {\n      scrollRef.current.scrollTop = _scrollTop;\n    }\n  }, []);\n\n  // Handle first interaction\n  useEffect(() => {\n    const handleFirstInteraction = async () => {\n      if (!firstInteraction) return;\n\n      const isActualResume =\n        firstInteraction === 'resume' &&\n        messages[0]?.output.toLowerCase() !== 'resume';\n\n      if (isActualResume) return;\n\n      await fetchThreads(undefined, true);\n\n      const currentPage = new URL(window.location.href);\n      if (threadId && currentPage.pathname === '/') {\n        navigate(`/thread/${threadId}`);\n      }\n    };\n\n    handleFirstInteraction();\n  }, [firstInteraction]);\n\n  // Reorder thread to top when a new message is sent in the current thread\n  useEffect(() => {\n    const currentCount = messages.length;\n    const prevCount = prevMessageCountRef.current;\n    prevMessageCountRef.current = currentCount;\n\n    if (\n      threadId &&\n      currentCount > prevCount &&\n      prevCount > 0 &&\n      threadHistory?.threads\n    ) {\n      const lastMessage = messages[currentCount - 1];\n      if (lastMessage?.type === 'user_message') {\n        setThreadHistory((prev) => {\n          if (!prev?.threads) return prev;\n          const threadIndex = prev.threads.findIndex((t) => t.id === threadId);\n          if (threadIndex <= 0) return prev; // Already at top or not found\n          const updatedThreads = [...prev.threads];\n          updatedThreads[threadIndex] = {\n            ...updatedThreads[threadIndex],\n            createdAt: new Date().toISOString()\n          };\n          return { ...prev, threads: updatedThreads };\n        });\n      }\n    }\n  }, [messages.length, threadId]);\n\n  const handleScroll = () => {\n    if (!scrollRef.current) return;\n    const { scrollHeight, clientHeight, scrollTop } = scrollRef.current;\n    const atBottom = scrollTop + clientHeight >= scrollHeight - 10;\n\n    _scrollTop = scrollTop;\n    setShouldLoadMore(atBottom);\n  };\n\n  const fetchThreads = async (\n    cursor?: string | number,\n    isLoadingMore = false\n  ) => {\n    try {\n      setIsLoadingMore(!!cursor || isLoadingMore);\n      setIsFetching(!cursor && !isLoadingMore);\n\n      const { pageInfo, data } = await apiClient.listThreads(\n        { first: BATCH_SIZE, cursor },\n        {}\n      );\n\n      setError(undefined);\n\n      // Prevent duplicate threads\n      const allThreads = uniqBy(\n        cursor ? threadHistory?.threads?.concat(data) : data,\n        'id'\n      );\n\n      if (allThreads) {\n        setThreadHistory((prev) => ({\n          ...prev,\n          pageInfo,\n          threads: allThreads\n        }));\n      }\n    } catch (err) {\n      setError(err instanceof Error ? err.message : 'Unknown error occurred');\n    } finally {\n      setShouldLoadMore(false);\n      setIsLoadingMore(false);\n      setIsFetching(false);\n    }\n  };\n\n  // Initial fetch\n  useEffect(() => {\n    if (!isFetching && !threadHistory?.threads && !error) {\n      fetchThreads();\n    }\n  }, [isFetching, threadHistory, error]);\n\n  // Handle infinite scroll\n  useEffect(() => {\n    if (threadHistory?.pageInfo) {\n      const { hasNextPage, endCursor } = threadHistory.pageInfo;\n\n      if (shouldLoadMore && !isLoadingMore && hasNextPage && endCursor) {\n        fetchThreads(endCursor);\n      }\n    }\n  }, [shouldLoadMore, isLoadingMore, threadHistory]);\n\n  return (\n    <SidebarContent onScroll={handleScroll} ref={scrollRef}>\n      <SidebarGroup>\n        <SidebarMenu>\n          {threadHistory ? (\n            <div id=\"thread-history\" className=\"flex-grow\">\n              <ThreadList\n                threadHistory={threadHistory}\n                error={error}\n                isFetching={isFetching}\n                isLoadingMore={isLoadingMore}\n              />\n            </div>\n          ) : null}\n        </SidebarMenu>\n      </SidebarGroup>\n    </SidebarContent>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LeftSidebar/ThreadList.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { size } from 'lodash';\nimport { Share2 } from 'lucide-react';\nimport { useContext, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  ClientError,\n  ThreadHistory, // sessionIdState,\n  threadHistoryState,\n  useChatInteract,\n  useChatMessages,\n  useChatSession,\n  useConfig\n} from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { Loader } from '@/components/Loader';\nimport ShareDialog from '@/components/share/ShareDialog';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle\n} from '@/components/ui/alert-dialog';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/dialog';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport {\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem\n} from '@/components/ui/sidebar';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { Translator } from '../i18n';\nimport ThreadOptions from './ThreadOptions';\n\ninterface ThreadListProps {\n  threadHistory?: ThreadHistory;\n  error?: string;\n  isFetching: boolean;\n  isLoadingMore: boolean;\n}\n\nexport function ThreadList({\n  threadHistory,\n  error,\n  isFetching,\n  isLoadingMore\n}: ThreadListProps) {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const { idToResume } = useChatSession();\n  const { clear } = useChatInteract();\n  const { threadId: currentThreadId } = useChatMessages();\n  const [threadIdToDelete, setThreadIdToDelete] = useState<string>();\n  const [threadIdToRename, setThreadIdToRename] = useState<string>();\n  const [threadNewName, setThreadNewName] = useState<string>();\n  const setThreadHistory = useSetRecoilState(threadHistoryState);\n  const apiClient = useContext(ChainlitContext);\n  const { config } = useConfig();\n  const dataPersistence = config?.dataPersistence;\n  const threadSharingReady = Boolean((config as any)?.threadSharing);\n  // sessionId not needed here\n\n  // Share thread state\n  const [threadIdToShare, setThreadIdToShare] = useState<string | undefined>();\n  const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);\n  // Share dialog state is centralized in ShareDialog; we only track which thread to share\n\n  const handleShareThread = (threadId: string) => {\n    if (!threadSharingReady) return;\n    setThreadIdToShare(threadId);\n    setIsShareDialogOpen(true);\n    // ShareDialog handles its own internal state; we just open it\n  };\n\n  const sortedTimeGroupKeys = useMemo(() => {\n    if (!threadHistory?.timeGroupedThreads) return [];\n    const fixedOrder = [\n      'Today',\n      'Yesterday',\n      'Previous 7 days',\n      'Previous 30 days'\n    ];\n    return Object.keys(threadHistory.timeGroupedThreads).sort((a, b) => {\n      const aIndex = fixedOrder.indexOf(a);\n      const bIndex = fixedOrder.indexOf(b);\n      if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;\n      if (aIndex !== -1) return -1;\n      if (bIndex !== -1) return 1;\n      return a.localeCompare(b);\n    });\n  }, [threadHistory?.timeGroupedThreads]);\n\n  if (isFetching || (!threadHistory?.timeGroupedThreads && isLoadingMore)) {\n    return (\n      <div className=\"flex items-center justify-center p-2\">\n        <Loader />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <Alert variant=\"error\" className=\"m-3\">\n        {error}\n      </Alert>\n    );\n  }\n\n  if (!threadHistory || size(threadHistory?.timeGroupedThreads) === 0) {\n    return (\n      <Alert variant=\"info\" className=\"m-3\">\n        <Translator path=\"threadHistory.sidebar.empty\" />\n      </Alert>\n    );\n  }\n\n  const handleDeleteThread = async () => {\n    if (!threadIdToDelete) return;\n    if (\n      threadIdToDelete === idToResume ||\n      threadIdToDelete === currentThreadId\n    ) {\n      clear();\n      await new Promise((resolve) => setTimeout(resolve, 300));\n    }\n\n    toast.promise(apiClient.deleteThread(threadIdToDelete), {\n      loading: (\n        <Translator path=\"threadHistory.thread.actions.delete.inProgress\" />\n      ),\n      success: () => {\n        setThreadHistory((prev) => ({\n          ...prev,\n          threads: prev?.threads?.filter((t) => t.id !== threadIdToDelete)\n        }));\n        navigate('/');\n        return (\n          <Translator path=\"threadHistory.thread.actions.delete.success\" />\n        );\n      },\n      error: (err) => {\n        if (err instanceof ClientError) {\n          return <span>{err.message}</span>;\n        } else {\n          return <span></span>;\n        }\n      }\n    });\n  };\n\n  const handleRenameThread = () => {\n    if (!threadIdToRename || !threadNewName) return;\n\n    toast.promise(apiClient.renameThread(threadIdToRename, threadNewName), {\n      loading: (\n        <Translator path=\"threadHistory.thread.actions.rename.inProgress\" />\n      ),\n      success: () => {\n        setThreadNewName(undefined);\n        setThreadIdToRename(undefined);\n        setThreadHistory((prev) => {\n          const next = {\n            ...prev,\n            threads: prev?.threads ? [...prev.threads] : undefined\n          };\n          const threadIndex = next.threads?.findIndex(\n            (t) => t.id === threadIdToRename\n          );\n          if (typeof threadIndex === 'number' && next.threads) {\n            next.threads[threadIndex] = {\n              ...next.threads[threadIndex],\n              name: threadNewName\n            };\n          }\n          return next;\n        });\n        return (\n          <div>\n            <Translator path=\"threadHistory.thread.actions.rename.success\" />\n          </div>\n        );\n      },\n      error: (err) => {\n        if (err instanceof ClientError) {\n          return <span>{err.message}</span>;\n        } else {\n          return <span></span>;\n        }\n      }\n    });\n  };\n\n  const getTimeGroupLabel = (group: string) => {\n    const labels = {\n      Today: <Translator path=\"threadHistory.sidebar.timeframes.today\" />,\n      Yesterday: (\n        <Translator path=\"threadHistory.sidebar.timeframes.yesterday\" />\n      ),\n      'Previous 7 days': (\n        <Translator path=\"threadHistory.sidebar.timeframes.previous7days\" />\n      ),\n      'Previous 30 days': (\n        <Translator path=\"threadHistory.sidebar.timeframes.previous30days\" />\n      )\n    };\n    return labels[group as keyof typeof labels] || group;\n  };\n\n  return (\n    <>\n      <AlertDialog\n        open={!!threadIdToDelete}\n        onOpenChange={() => setThreadIdToDelete(undefined)}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>\n              <Translator path=\"threadHistory.thread.actions.delete.title\" />\n            </AlertDialogTitle>\n            <AlertDialogDescription>\n              <Translator path=\"threadHistory.thread.actions.delete.description\" />\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter className=\"flex-row gap-2 sm:gap-0\">\n            <AlertDialogCancel className=\"mt-0\">\n              <Translator path=\"common.actions.cancel\" />\n            </AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteThread}>\n              <Translator path=\"common.actions.confirm\" />\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n      <Dialog\n        open={!!threadIdToRename}\n        onOpenChange={() => setThreadIdToRename(undefined)}\n      >\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>\n              <Translator path=\"threadHistory.thread.actions.rename.title\" />\n            </DialogTitle>\n            <DialogDescription>\n              <Translator path=\"threadHistory.thread.actions.rename.description\" />\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"my-6\">\n            <Label htmlFor=\"name\" className=\"text-right\">\n              <Translator path=\"threadHistory.thread.actions.rename.form.name.label\" />\n            </Label>\n            <Input\n              id=\"name\"\n              required\n              value={threadNewName}\n              onChange={(e) => setThreadNewName(e.target.value)}\n              placeholder={t(\n                'threadHistory.thread.actions.rename.form.name.placeholder'\n              )}\n              autoFocus\n            />\n          </div>\n          <DialogFooter>\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => setThreadIdToRename(undefined)}\n            >\n              <Translator path=\"common.actions.cancel\" />\n            </Button>\n            <Button type=\"button\" onClick={handleRenameThread}>\n              <Translator path=\"common.actions.confirm\" />\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n      <ShareDialog\n        open={isShareDialogOpen}\n        onOpenChange={(open) => {\n          setIsShareDialogOpen(open);\n          if (!open) {\n            setThreadIdToShare(undefined);\n          }\n        }}\n        threadId={threadIdToShare || null}\n      />\n      <TooltipProvider delayDuration={300}>\n        {sortedTimeGroupKeys.map((group) => {\n          const items = threadHistory!.timeGroupedThreads![group];\n          return (\n            <SidebarGroup key={group}>\n              <SidebarGroupLabel>{getTimeGroupLabel(group)}</SidebarGroupLabel>\n              <SidebarGroupContent>\n                <SidebarMenu>\n                  {items.map((thread) => {\n                    const isResumed =\n                      idToResume === thread.id &&\n                      !threadHistory!.currentThreadId;\n                    const isSelected =\n                      isResumed || threadHistory!.currentThreadId === thread.id;\n                    return (\n                      <SidebarMenuItem\n                        key={thread.id}\n                        id={`thread-${thread.id}`}\n                      >\n                        <Tooltip>\n                          <TooltipTrigger asChild>\n                            <Link to={isResumed ? '' : `/thread/${thread.id}`}>\n                              <SidebarMenuButton\n                                isActive={isSelected}\n                                className=\"relative h-9 group/thread\"\n                              >\n                                <span className=\"flex min-w-0 items-center gap-2\">\n                                  {thread.metadata?.is_shared ? (\n                                    <Share2\n                                      className=\"h-4 w-4 shrink-0 text-muted-foreground\"\n                                      aria-hidden=\"true\"\n                                    />\n                                  ) : null}\n                                  <span className=\"truncate\">\n                                    {thread.name || (\n                                      <Translator path=\"threadHistory.thread.untitled\" />\n                                    )}\n                                  </span>\n                                </span>\n                                <div\n                                  className={cn(\n                                    'absolute w-10 bottom-0 top-0 right-0 bg-gradient-to-l from-[hsl(var(--sidebar-background))] to-transparent'\n                                  )}\n                                />\n                                <ThreadOptions\n                                  onDelete={() =>\n                                    setThreadIdToDelete(thread.id)\n                                  }\n                                  onRename={() => {\n                                    setThreadIdToRename(thread.id);\n                                    setThreadNewName(thread.name);\n                                  }}\n                                  onShare={\n                                    dataPersistence && threadSharingReady\n                                      ? () => handleShareThread(thread.id)\n                                      : undefined\n                                  }\n                                  className={cn(\n                                    'absolute z-20 bottom-0 top-0 right-0 bg-sidebar-accent hover:bg-sidebar-accent hover:text-primary flex opacity-0 group-hover/thread:opacity-100',\n                                    isSelected &&\n                                      'bg-sidebar-accent opacity-100'\n                                  )}\n                                />\n                              </SidebarMenuButton>\n                            </Link>\n                          </TooltipTrigger>\n                          <TooltipContent side=\"right\" align=\"center\">\n                            <p>{thread.name}</p>\n                          </TooltipContent>\n                        </Tooltip>\n                      </SidebarMenuItem>\n                    );\n                  })}\n                </SidebarMenu>\n              </SidebarGroupContent>\n            </SidebarGroup>\n          );\n        })}\n      </TooltipProvider>\n      {isLoadingMore ? (\n        <div className=\"flex items-center justify-center p-2\">\n          <Loader />\n        </div>\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LeftSidebar/ThreadOptions.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Ellipsis, Share2, Trash2 } from 'lucide-react';\n\nimport { Pencil } from '@/components/icons/Pencil';\nimport { buttonVariants } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger\n} from '@/components/ui/dropdown-menu';\n\nimport { Translator } from '../i18n';\n\ninterface Props {\n  onDelete: () => void;\n  onRename: () => void;\n  onShare?: () => void;\n  className?: string;\n}\n\nexport default function ThreadOptions({\n  onDelete,\n  onRename,\n  onShare,\n  className\n}: Props) {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <div\n          onClick={(e) => {\n            e.stopPropagation();\n            e.preventDefault();\n          }}\n          id=\"thread-options\"\n          className={cn(\n            buttonVariants({ variant: 'ghost', size: 'icon' }),\n            'focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-muted-foreground',\n            className\n          )}\n        >\n          <Ellipsis />\n        </div>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-20\" align=\"start\" forceMount>\n        <DropdownMenuItem\n          id=\"rename-thread\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onRename();\n          }}\n        >\n          <Translator path=\"threadHistory.thread.menu.rename\" />\n          <Pencil className=\"ml-auto\" />\n        </DropdownMenuItem>\n        {onShare && (\n          <DropdownMenuItem\n            id=\"share-thread\"\n            onClick={(e) => {\n              e.stopPropagation();\n              onShare();\n            }}\n          >\n            <Translator path=\"threadHistory.thread.menu.share\" />\n            <Share2 className=\"ml-auto\" />\n          </DropdownMenuItem>\n        )}\n        <DropdownMenuItem\n          id=\"delete-thread\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onDelete();\n          }}\n          className=\"text-red-500 focus:text-red-500\"\n        >\n          <Translator path=\"threadHistory.thread.menu.delete\" />\n          <Trash2 className=\"ml-auto\" />\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LeftSidebar/index.tsx",
    "content": "import { useNavigate } from 'react-router-dom';\n\nimport SidebarTrigger from '@/components/header/SidebarTrigger';\nimport { Sidebar, SidebarHeader, SidebarRail } from '@/components/ui/sidebar';\n\nimport NewChatButton from '../header/NewChat';\nimport SearchChats from './Search';\nimport { ThreadHistory } from './ThreadHistory';\n\nexport default function LeftSidebar({\n  ...props\n}: React.ComponentProps<typeof Sidebar>) {\n  const navigate = useNavigate();\n  return (\n    <Sidebar {...props} className=\"border-none\">\n      <SidebarHeader className=\"py-3\">\n        <div className=\"flex items-center justify-between\">\n          <SidebarTrigger />\n          <div className=\"flex items-center\">\n            <SearchChats />\n            <NewChatButton navigate={navigate} />\n          </div>\n        </div>\n      </SidebarHeader>\n      <ThreadHistory />\n      <SidebarRail />\n    </Sidebar>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Loader.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { LoaderIcon } from 'lucide-react';\n\ninterface LoaderProps {\n  className?: string;\n}\n\nconst Loader = ({ className }: LoaderProps): JSX.Element => {\n  return (\n    <LoaderIcon\n      className={cn('h-4 w-4 animate-spin text-primary', className)}\n    />\n  );\n};\n\nexport { Loader };\n"
  },
  {
    "path": "frontend/src/components/LoginForm.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Eye, EyeOff } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { useForm } from 'react-hook-form';\n\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport Translator, { useTranslation } from 'components/i18n/Translator';\n\nimport { ClientError } from '@chainlit/react-client';\n\nimport Alert from './Alert';\nimport { ProviderButton } from './ProviderButton';\n\ninterface Props {\n  error?: string;\n  providers: string[];\n  callbackUrl: string;\n  onPasswordSignIn?: (\n    email: string,\n    password: string,\n    callbackUrl: string\n  ) => Promise<any>;\n  onOAuthSignIn?: (provider: string, callbackUrl: string) => Promise<any>;\n}\n\ninterface FormValues {\n  email: string;\n  password: string;\n}\n\nexport function LoginForm({\n  providers,\n  onPasswordSignIn,\n  onOAuthSignIn,\n  callbackUrl,\n  error\n}: Props) {\n  const [loading, setLoading] = useState(false);\n  const [errorState, setErrorState] = useState(error);\n  const [showPassword, setShowPassword] = useState(false);\n\n  const { t } = useTranslation();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { errors, touchedFields }\n  } = useForm<FormValues>({\n    defaultValues: {\n      email: '',\n      password: ''\n    }\n  });\n\n  useEffect(() => {\n    setErrorState(error);\n  }, [error]);\n\n  const onSubmit = async (data: FormValues) => {\n    if (!onPasswordSignIn) return;\n\n    setLoading(true);\n    try {\n      await onPasswordSignIn(data.email, data.password, callbackUrl);\n    } catch (err) {\n      if (err instanceof ClientError && err.detail) {\n        setErrorState(err.detail);\n      } else if (err instanceof Error) {\n        setErrorState(err.message);\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const oAuthReady = onOAuthSignIn && providers.length;\n\n  return (\n    <form\n      onSubmit={handleSubmit(onSubmit)}\n      className={cn('flex flex-col gap-6')}\n    >\n      <div className=\"flex flex-col items-center gap-2 text-center\">\n        <h1 className=\"text-2xl font-bold\">\n          <Translator path=\"auth.login.title\" />\n        </h1>\n      </div>\n\n      {errorState && (\n        <Alert variant=\"error\">\n          {t([\n            `auth.login.errors.${errorState.toLowerCase()}`,\n            `auth.login.errors.default`\n          ])}\n        </Alert>\n      )}\n\n      <div className=\"grid gap-6\">\n        {onPasswordSignIn && (\n          <>\n            <div className=\"grid gap-2\">\n              <Label htmlFor=\"email\">\n                <Translator path=\"auth.login.form.email.label\" />\n              </Label>\n              <Input\n                id=\"email\"\n                disabled={loading}\n                autoFocus\n                placeholder={t('auth.login.form.email.placeholder')}\n                {...register('email', {\n                  required: t('auth.login.form.email.required')\n                })}\n                className={cn(\n                  touchedFields.email && errors.email && 'border-destructive'\n                )}\n              />\n              {touchedFields.email && errors.email && (\n                <p className=\"text-sm text-destructive\">\n                  {errors.email.message}\n                </p>\n              )}\n            </div>\n\n            <div className=\"grid gap-2\">\n              <div className=\"flex items-center justify-between\">\n                <Label htmlFor=\"password\">\n                  <Translator path=\"auth.login.form.password.label\" />\n                </Label>\n              </div>\n              <div className=\"relative\">\n                <Input\n                  id=\"password\"\n                  disabled={loading}\n                  type={showPassword ? 'text' : 'password'}\n                  {...register('password', {\n                    required: t('auth.login.form.password.required')\n                  })}\n                  className={cn(\n                    touchedFields.password &&\n                      errors.password &&\n                      'border-destructive'\n                  )}\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"absolute right-2 top-1/2 -translate-y-1/2\"\n                  onClick={() => setShowPassword(!showPassword)}\n                >\n                  {showPassword ? (\n                    <EyeOff className=\"h-4 w-4\" />\n                  ) : (\n                    <Eye className=\"h-4 w-4\" />\n                  )}\n                </Button>\n              </div>\n              {touchedFields.password && errors.password && (\n                <p className=\"text-sm text-destructive\">\n                  {errors.password.message}\n                </p>\n              )}\n            </div>\n\n            <Button type=\"submit\" className=\"w-full\" disabled={loading}>\n              <Translator path=\"auth.login.form.actions.signin\" />\n            </Button>\n          </>\n        )}\n\n        {onPasswordSignIn && oAuthReady ? (\n          <div className=\"relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border\">\n            <span className=\"relative z-10 bg-background px-2 text-muted-foreground\">\n              <Translator path=\"auth.login.form.alternativeText.or\" />\n            </span>\n          </div>\n        ) : null}\n\n        {oAuthReady ? (\n          <div className=\"grid gap-2\">\n            {providers.map((provider, index) => (\n              <ProviderButton\n                key={`provider-${index}`}\n                provider={provider}\n                onClick={() => onOAuthSignIn?.(provider, callbackUrl)}\n              />\n            ))}\n          </div>\n        ) : null}\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Logo.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { useContext } from 'react';\n\nimport { ChainlitContext, useConfig } from '@chainlit/react-client';\n\nimport { useTheme } from './ThemeProvider';\n\ninterface Props {\n  className?: string;\n}\n\nexport const Logo = ({ className }: Props) => {\n  const { variant } = useTheme();\n  const { config } = useConfig();\n  const apiClient = useContext(ChainlitContext);\n\n  return (\n    <img\n      src={apiClient.getLogoEndpoint(variant, config?.ui?.logo_file_url)}\n      alt=\"logo\"\n      className={cn('logo', className)}\n    />\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/Markdown.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { omit } from 'lodash';\nimport { useContext, useMemo } from 'react';\nimport ReactMarkdown from 'react-markdown';\nimport { PluggableList } from 'react-markdown/lib';\nimport rehypeKatex from 'rehype-katex';\nimport rehypeRaw from 'rehype-raw';\nimport remarkDirective from 'remark-directive';\nimport remarkGfm from 'remark-gfm';\nimport remarkMath from 'remark-math';\nimport { visit } from 'unist-util-visit';\n\nimport { ChainlitContext, type IMessageElement } from '@chainlit/react-client';\n\nimport { AspectRatio } from '@/components/ui/aspect-ratio';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow\n} from '@/components/ui/table';\n\nimport BlinkingCursor from './BlinkingCursor';\nimport CodeSnippet from './CodeSnippet';\nimport { ElementRef } from './Elements/ElementRef';\nimport {\n  type AlertProps,\n  MarkdownAlert,\n  alertComponents,\n  normalizeAlertType\n} from './MarkdownAlert';\n\ninterface Props {\n  allowHtml?: boolean;\n  latex?: boolean;\n  renderMarkdown?: boolean;\n  refElements?: IMessageElement[];\n  children: string;\n  className?: string;\n}\n\nconst cursorPlugin = () => {\n  return (tree: any) => {\n    visit(tree, 'text', (node: any, index, parent) => {\n      const placeholderPattern = /\\u200B/g;\n      const matches = [...(node.value?.matchAll(placeholderPattern) || [])];\n\n      if (matches.length > 0) {\n        const newNodes: any[] = [];\n        let lastIndex = 0;\n\n        matches.forEach((match) => {\n          const [fullMatch] = match;\n          const startIndex = match.index!;\n          const endIndex = startIndex + fullMatch.length;\n\n          if (startIndex > lastIndex) {\n            newNodes.push({\n              type: 'text',\n              value: node.value!.slice(lastIndex, startIndex)\n            });\n          }\n\n          newNodes.push({\n            type: 'blinkingCursor',\n            data: {\n              hName: 'blinkingCursor',\n              hProperties: { text: 'Blinking Cursor' }\n            }\n          });\n\n          lastIndex = endIndex;\n        });\n\n        if (lastIndex < node.value!.length) {\n          newNodes.push({\n            type: 'text',\n            value: node.value!.slice(lastIndex)\n          });\n        }\n\n        parent!.children.splice(index, 1, ...newNodes);\n      }\n    });\n  };\n};\n\nconst Markdown = ({\n  allowHtml,\n  latex,\n  renderMarkdown,\n  refElements,\n  className,\n  children\n}: Props) => {\n  const apiClient = useContext(ChainlitContext);\n\n  if (renderMarkdown === false) {\n    return (\n      <pre\n        className={cn('whitespace-pre-wrap break-words', className)}\n        style={{ fontFamily: 'inherit' }}\n      >\n        {children}\n      </pre>\n    );\n  }\n\n  const rehypePlugins = useMemo(() => {\n    let rehypePlugins: PluggableList = [];\n    if (allowHtml) {\n      rehypePlugins = [rehypeRaw as any, ...rehypePlugins];\n    }\n    if (latex) {\n      rehypePlugins = [rehypeKatex as any, ...rehypePlugins];\n    }\n    return rehypePlugins;\n  }, [allowHtml, latex]);\n\n  const remarkPlugins = useMemo(() => {\n    let remarkPlugins: PluggableList = [\n      cursorPlugin,\n      remarkGfm as any,\n      remarkDirective as any,\n      MarkdownAlert\n    ];\n\n    if (latex) {\n      remarkPlugins = [...remarkPlugins, remarkMath as any];\n    }\n    return remarkPlugins;\n  }, [latex]);\n\n  return (\n    <ReactMarkdown\n      className={cn('prose lg:prose-xl', className)}\n      remarkPlugins={remarkPlugins}\n      rehypePlugins={rehypePlugins}\n      components={{\n        ...alertComponents, // add alert components\n        code(props) {\n          return (\n            <code\n              {...omit(props, ['node'])}\n              className=\"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold\"\n            />\n          );\n        },\n        pre({ children, ...props }: any) {\n          return <CodeSnippet {...props} />;\n        },\n        a({ children, ...props }) {\n          const name = children as string;\n          const element = refElements?.find((e) => e.name === name);\n          if (element) {\n            return <ElementRef element={element} />;\n          } else {\n            return (\n              <a\n                {...props}\n                className=\"text-primary hover:underline\"\n                target=\"_blank\"\n              >\n                {children}\n              </a>\n            );\n          }\n        },\n        img: (image: any) => {\n          // Check if the image source is actually a video file\n          const src = image.src.startsWith('/public')\n            ? apiClient.buildEndpoint(image.src)\n            : image.src;\n\n          const videoExtensions = [\n            '.mp4',\n            '.webm',\n            '.mov',\n            '.avi',\n            '.ogv',\n            '.m4v'\n          ];\n          const isVideo = videoExtensions.some((ext) =>\n            src.toLowerCase().split(/[?#]/)[0].endsWith(ext)\n          );\n\n          if (isVideo) {\n            return (\n              <div className=\"sm:max-w-sm md:max-w-md\">\n                <video\n                  src={src}\n                  controls\n                  className=\"w-full h-auto rounded-md\"\n                  style={{ maxWidth: '100%' }}\n                >\n                  Your browser does not support the video tag.\n                </video>\n              </div>\n            );\n          }\n\n          return (\n            <div className=\"sm:max-w-sm md:max-w-md\">\n              <AspectRatio\n                ratio={16 / 9}\n                className=\"bg-muted rounded-md overflow-hidden\"\n              >\n                <img\n                  src={src}\n                  alt={image.alt}\n                  className=\"h-full w-full object-contain\"\n                />\n              </AspectRatio>\n            </div>\n          );\n        },\n        blockquote(props) {\n          return (\n            <blockquote\n              {...omit(props, ['node'])}\n              className=\"mt-6 border-l-2 pl-6 italic\"\n            />\n          );\n        },\n        em(props) {\n          return <span {...omit(props, ['node'])} className=\"italic\" />;\n        },\n        strong(props) {\n          return <span {...omit(props, ['node'])} className=\"font-bold\" />;\n        },\n        hr() {\n          return <Separator />;\n        },\n        ul(props) {\n          return (\n            <ul\n              {...omit(props, ['node'])}\n              className=\"my-3 ml-3 list-disc pl-2 [&>li]:mt-1\"\n            />\n          );\n        },\n        ol(props) {\n          return (\n            <ol\n              {...omit(props, ['node'])}\n              className=\"my-3 ml-3 list-decimal pl-2 [&>li]:mt-1\"\n            />\n          );\n        },\n        h1(props) {\n          return (\n            <h1\n              {...omit(props, ['node'])}\n              className=\"scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mt-8 first:mt-0\"\n            />\n          );\n        },\n        h2(props) {\n          return (\n            <h2\n              {...omit(props, ['node'])}\n              className=\"scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight mt-8 first:mt-0\"\n            />\n          );\n        },\n        h3(props) {\n          return (\n            <h3\n              {...omit(props, ['node'])}\n              className=\"scroll-m-20 text-2xl font-semibold tracking-tight mt-6 first:mt-0\"\n            />\n          );\n        },\n        h4(props) {\n          return (\n            <h4\n              {...omit(props, ['node'])}\n              className=\"scroll-m-20 text-xl font-semibold tracking-tight mt-6 first:mt-0\"\n            />\n          );\n        },\n        p(props) {\n          return (\n            <div\n              {...omit(props, ['node'])}\n              className=\"leading-7 [&:not(:first-child)]:mt-4 whitespace-pre-wrap break-words\"\n              role=\"article\"\n            />\n          );\n        },\n        table({ children, ...props }) {\n          return (\n            <Card className=\"[&:not(:first-child)]:mt-2 [&:not(:last-child)]:mb-2\">\n              <Table {...(props as any)}>{children}</Table>\n            </Card>\n          );\n        },\n        thead({ children, ...props }) {\n          return <TableHeader {...(props as any)}>{children}</TableHeader>;\n        },\n        tr({ children, ...props }) {\n          return <TableRow {...(props as any)}>{children}</TableRow>;\n        },\n        th({ children, ...props }) {\n          return <TableHead {...(props as any)}>{children}</TableHead>;\n        },\n        td({ children, ...props }) {\n          return <TableCell {...(props as any)}>{children}</TableCell>;\n        },\n        tbody({ children, ...props }) {\n          return <TableBody {...(props as any)}>{children}</TableBody>;\n        },\n        // @ts-expect-error custom plugin\n        blinkingCursor: () => <BlinkingCursor whitespace />,\n        alert: ({\n          type,\n          children,\n          ...props\n        }: AlertProps & { type?: string }) => {\n          const alertType = normalizeAlertType(type || props.variant || 'info');\n          return alertComponents.Alert({ variant: alertType, children });\n        }\n      }}\n    >\n      {children}\n    </ReactMarkdown>\n  );\n};\n\nexport { Markdown };\n"
  },
  {
    "path": "frontend/src/components/MarkdownAlert.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport {\n  AlertCircle,\n  AlertTriangle,\n  BellRing,\n  BookOpen,\n  Bug,\n  CheckCircle,\n  Clock,\n  Heart,\n  HelpCircle,\n  Info,\n  Lightbulb,\n  Rocket,\n  Shield\n} from 'lucide-react';\n// unist-util-visit is a utility for walking AST (Abstract Syntax Tree) nodes in markdown processing,\n// used here to find and transform ::: alert syntax into Alert components\nimport { visit } from 'unist-util-visit';\n\nimport { useConfig } from '@chainlit/react-client';\n\nimport { useTranslation } from '@/components/i18n/Translator';\n\nexport interface AlertProps {\n  variant: AlertVariant;\n  children?: React.ReactNode;\n}\n// Alert type definition\nexport const AlertTypes = [\n  'info',\n  'note',\n  'tip',\n  'important',\n  'warning',\n  'caution',\n  'debug',\n  'example',\n  'success',\n  'help',\n  'idea',\n  'pending',\n  'security',\n  'beta',\n  'best-practice'\n  // 'your-new-type';\n] as const;\nexport type AlertVariant = (typeof AlertTypes)[number];\n// Styles and icon configuration\nconst variantStyles = {\n  // Basic alerts\n  info: {\n    container:\n      'bg-blue-50 border-l-4 border-l-blue-400 dark:bg-blue-950 dark:border-l-blue-500',\n    icon: 'text-blue-500 dark:text-blue-400',\n    text: 'text-blue-700 dark:text-blue-200',\n    Icon: Info\n  },\n  note: {\n    container:\n      'bg-gray-50 border-l-4 border-l-gray-400 dark:bg-gray-900 dark:border-l-gray-500',\n    icon: 'text-gray-500 dark:text-gray-400',\n    text: 'text-gray-700 dark:text-gray-200',\n    Icon: BellRing\n  },\n  tip: {\n    container:\n      'bg-green-50 border-l-4 border-l-green-400 dark:bg-green-950 dark:border-l-green-500',\n    icon: 'text-green-500 dark:text-green-400',\n    text: 'text-green-700 dark:text-green-200',\n    Icon: CheckCircle\n  },\n  important: {\n    container:\n      'bg-purple-50 border-l-4 border-l-purple-400 dark:bg-purple-950 dark:border-l-purple-500',\n    icon: 'text-purple-500 dark:text-purple-400',\n    text: 'text-purple-700 dark:text-purple-200',\n    Icon: AlertCircle\n  },\n  warning: {\n    container:\n      'bg-yellow-50 border-l-4 border-l-yellow-400 dark:bg-yellow-950 dark:border-l-yellow-500',\n    icon: 'text-yellow-500 dark:text-yellow-400',\n    text: 'text-yellow-700 dark:text-yellow-200',\n    Icon: AlertTriangle\n  },\n  caution: {\n    container:\n      'bg-red-50 border-l-4 border-l-red-400 dark:bg-red-950 dark:border-l-red-500',\n    icon: 'text-red-500 dark:text-red-400',\n    text: 'text-red-700 dark:text-red-200',\n    Icon: AlertTriangle\n  },\n\n  // Development related\n  debug: {\n    container:\n      'bg-gray-50 border-l-4 border-l-gray-400 dark:bg-gray-900 dark:border-l-gray-500',\n    icon: 'text-gray-500 dark:text-gray-400',\n    text: 'text-gray-700 dark:text-gray-200',\n    Icon: Bug\n  },\n  example: {\n    container:\n      'bg-indigo-50 border-l-4 border-l-indigo-400 dark:bg-indigo-950 dark:border-l-indigo-500',\n    icon: 'text-indigo-500 dark:text-indigo-400',\n    text: 'text-indigo-700 dark:text-indigo-200',\n    Icon: BookOpen\n  },\n\n  // Functional alerts\n  success: {\n    container:\n      'bg-green-50 border-l-4 border-l-green-400 dark:bg-green-950 dark:border-l-green-500',\n    icon: 'text-green-500 dark:text-green-400',\n    text: 'text-green-700 dark:text-green-200',\n    Icon: CheckCircle\n  },\n  help: {\n    container:\n      'bg-blue-50 border-l-4 border-l-blue-400 dark:bg-blue-950 dark:border-l-blue-500',\n    icon: 'text-blue-500 dark:text-blue-400',\n    text: 'text-blue-700 dark:text-blue-200',\n    Icon: HelpCircle\n  },\n  idea: {\n    container:\n      'bg-yellow-50 border-l-4 border-l-yellow-400 dark:bg-yellow-950 dark:border-l-yellow-500',\n    icon: 'text-yellow-500 dark:text-yellow-400',\n    text: 'text-yellow-700 dark:text-yellow-200',\n    Icon: Lightbulb\n  },\n\n  // Status alerts\n  pending: {\n    container:\n      'bg-orange-50 border-l-4 border-l-orange-400 dark:bg-orange-950 dark:border-l-orange-500',\n    icon: 'text-orange-500 dark:text-orange-400',\n    text: 'text-orange-700 dark:text-orange-200',\n    Icon: Clock\n  },\n  security: {\n    container:\n      'bg-slate-50 border-l-4 border-l-slate-400 dark:bg-slate-950 dark:border-l-slate-500',\n    icon: 'text-slate-500 dark:text-slate-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Shield\n  },\n  beta: {\n    container:\n      'bg-violet-50 border-l-4 border-l-violet-400 dark:bg-violet-950 dark:border-l-violet-500',\n    icon: 'text-violet-500 dark:text-violet-400',\n    text: 'text-violet-700 dark:text-violet-200',\n    Icon: Rocket\n  },\n  'best-practice': {\n    container:\n      'bg-teal-50 border-l-4 border-l-teal-400 dark:bg-teal-950 dark:border-l-teal-500',\n    icon: 'text-teal-500 dark:text-teal-400',\n    text: 'text-teal-700 dark:text-teal-200',\n    Icon: Heart\n  }\n  // we can add new types here later, but remember to update translation.json file under \"alerts\".\n  //  'your-new-type': {\n  //    container: 'bg-teal-50 border-l-4 border-l-teal-400 dark:bg-teal-950 dark:border-l-teal-500',\n  //    icon: 'text-teal-500 dark:text-teal-400',\n  //    text: 'text-teal-700 dark:text-teal-200',\n  //    Icon: Heart\n  //  }\n};\nconst modernVariantStyles = {\n  // Basic alerts\n  info: {\n    container:\n      'bg-blue-50/80 rounded-2xl border border-blue-200 dark:bg-slate-800/30 dark:border-slate-500/40',\n    icon: 'text-blue-500 dark:text-blue-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Info\n  },\n  note: {\n    container:\n      'bg-gray-50/80 rounded-2xl border border-gray-300 dark:bg-gray-800/30 dark:border-gray-500/40',\n    icon: 'text-gray-500 dark:text-gray-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: BellRing\n  },\n  tip: {\n    container:\n      'bg-green-50/80 rounded-2xl border border-green-200 dark:bg-green-800/30 dark:border-green-600/30',\n    icon: 'text-green-500 dark:text-green-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: CheckCircle\n  },\n  important: {\n    container:\n      'bg-purple-50/80 rounded-2xl border border-purple-200 dark:bg-purple-800/20 dark:border-purple-600/30',\n    icon: 'text-purple-500 dark:text-purple-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: AlertCircle\n  },\n  warning: {\n    container:\n      'bg-yellow-50/80 rounded-2xl border border-yellow-200 dark:bg-yellow-800/30 dark:border-yellow-600/30',\n    icon: 'text-yellow-500 dark:text-yellow-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: AlertTriangle\n  },\n  caution: {\n    container:\n      'bg-red-50/80 rounded-2xl border border-red-200 dark:bg-red-900/30 dark:border-red-600/30',\n    icon: 'text-red-500 dark:text-red-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: AlertTriangle\n  },\n  debug: {\n    container:\n      'bg-gray-50/80 rounded-2xl border border-gray-300 dark:bg-gray-800/30 dark:border-gray-500/40',\n    icon: 'text-gray-500 dark:text-gray-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Bug\n  },\n  example: {\n    container:\n      'bg-indigo-50/80 rounded-2xl border border-indigo-200 dark:bg-indigo-800/30 dark:border-indigo-600/30',\n    icon: 'text-indigo-500 dark:text-indigo-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: BookOpen\n  },\n  success: {\n    container:\n      'bg-green-50/80 rounded-2xl border border-green-200 dark:bg-green-800/30 dark:border-green-600/30',\n    icon: 'text-green-500 dark:text-green-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: CheckCircle\n  },\n  help: {\n    container:\n      'bg-blue-50/80 rounded-2xl border border-blue-200 dark:bg-blue-800/30 dark:border-blue-600/30',\n    icon: 'text-blue-500 dark:text-blue-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: HelpCircle\n  },\n  idea: {\n    container:\n      'bg-yellow-50/80 rounded-2xl border border-yellow-200 dark:bg-yellow-800/30 dark:border-yellow-600/30',\n    icon: 'text-yellow-500 dark:text-yellow-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Lightbulb\n  },\n  pending: {\n    container:\n      'bg-orange-50/80 rounded-2xl border border-orange-200 dark:bg-orange-900/30 dark:border-orange-600/30',\n    icon: 'text-orange-500 dark:text-orange-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Clock\n  },\n  security: {\n    container:\n      'bg-slate-50/80 rounded-2xl border border-slate-300 dark:bg-slate-800/30 dark:border-slate-500/40',\n    icon: 'text-slate-500 dark:text-slate-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Shield\n  },\n  beta: {\n    container:\n      'bg-violet-50/80 rounded-2xl border border-violet-200 dark:bg-violet-800/20 dark:border-violet-600/30',\n    icon: 'text-violet-500 dark:text-violet-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Rocket\n  },\n  'best-practice': {\n    container:\n      'bg-teal-50/80 rounded-2xl border border-teal-200 dark:bg-teal-800/30 dark:border-teal-600/30',\n    icon: 'text-teal-500 dark:text-teal-400',\n    text: 'text-slate-700 dark:text-slate-200',\n    Icon: Heart\n  }\n};\n// Alert component\nconst AlertComponent = ({\n  variant,\n  children\n}: {\n  variant: AlertVariant;\n  children: React.ReactNode;\n}) => {\n  const { t } = useTranslation();\n  const configData = useConfig();\n  const useModernStyle = configData?.config?.ui?.alert_style === 'modern';\n  const styleSet = useModernStyle ? modernVariantStyles : variantStyles;\n  const style = styleSet[variant];\n  const Icon = style.Icon;\n\n  return (\n    <div className={cn('rounded-lg p-4 mb-4', style.container)}>\n      <div className=\"flex\">\n        <div className={cn('flex-shrink-0', style.icon)}>\n          <Icon className=\"w-5 h-5\" />\n        </div>\n        <div className=\"ml-3\">\n          <div className={cn('text-sm font-medium mb-1', style.text)}>\n            {t(`alerts.${variant}`)}\n          </div>\n          <div className={cn('text-sm', style.text)}>{children}</div>\n        </div>\n      </div>\n    </div>\n  );\n};\n// MarkdownAlert plugin\nexport const MarkdownAlert = () => {\n  return (tree: any) => {\n    visit(tree, 'text', (node) => {\n      const regex = /^:::\\s*([\\w-]+)\\n([\\s\\S]*?)\\n:::/i;\n      const match = node.value.match(regex);\n\n      if (match) {\n        const [, type, content] = match;\n        node.type = 'element';\n        node.data = {\n          hName: 'Alert',\n          hProperties: { variant: normalizeAlertType(type) }\n        };\n        node.children = [{ type: 'text', value: content.trim() }];\n      }\n    });\n  };\n};\nexport const normalizeAlertType = (type: string): AlertVariant => {\n  if (!type) return 'info';\n  const normalized = type.toLowerCase().replace(/[-_\\s]/g, '-');\n  if (!AlertTypes.includes(normalized as AlertVariant)) {\n    console.warn(`Invalid alert type \"${type}\", falling back to \"info\"`);\n    return 'info';\n  }\n  return normalized as AlertVariant;\n};\n\nexport const alertComponents = {\n  Alert: AlertComponent\n};\n"
  },
  {
    "path": "frontend/src/components/ProviderButton.tsx",
    "content": "import { useTranslation } from 'components/i18n/Translator';\nimport { Auth0 } from 'components/icons/Auth0';\nimport { Cognito } from 'components/icons/Cognito';\nimport { Descope } from 'components/icons/Descope';\nimport { GitHub } from 'components/icons/Github';\nimport { Gitlab } from 'components/icons/Gitlab';\nimport { Google } from 'components/icons/Google';\nimport { Microsoft } from 'components/icons/Microsoft';\nimport { Okta } from 'components/icons/Okta';\n\nimport { Button } from './ui/button';\n\nfunction capitalizeFirstLetter(string: string) {\n  return string.charAt(0).toUpperCase() + string.slice(1);\n}\n\nfunction getProviderName(provider: string) {\n  switch (provider) {\n    case 'azure-ad':\n    case 'azure-ad-hybrid':\n      return 'Microsoft';\n    case 'github':\n      return 'GitHub';\n    case 'okta':\n      return 'Okta';\n    case 'descope':\n      return 'Descope';\n    case 'aws-cognito':\n      return 'Cognito';\n    default:\n      return capitalizeFirstLetter(provider);\n  }\n}\n\nfunction renderProviderIcon(provider: string) {\n  switch (provider) {\n    case 'google':\n      return <Google />;\n    case 'github':\n      return <GitHub />;\n    case 'azure-ad':\n    case 'azure-ad-hybrid':\n      return <Microsoft />;\n    case 'okta':\n      return <Okta />;\n    case 'auth0':\n      return <Auth0 />;\n    case 'descope':\n      return <Descope />;\n    case 'aws-cognito':\n      return <Cognito />;\n    case 'gitlab':\n      return <Gitlab />;\n    default:\n      return null;\n  }\n}\n\ninterface ProviderButtonProps {\n  provider: string;\n  onClick: () => void;\n}\n\nconst ProviderButton = ({\n  provider,\n  onClick\n}: ProviderButtonProps): JSX.Element => {\n  const { t } = useTranslation();\n  return (\n    <Button type=\"button\" variant=\"outline\" onClick={onClick}>\n      {renderProviderIcon(provider.toLowerCase())}\n      {t('auth.provider.continue', {\n        provider: getProviderName(provider)\n      })}\n    </Button>\n  );\n};\n\nexport { ProviderButton };\n"
  },
  {
    "path": "frontend/src/components/QuiltedGrid.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nimport { IImageElement, IVideoElement } from '@chainlit/react-client';\n\nconst sizeToUnit = (element: IImageElement | IVideoElement) => {\n  switch (element.size) {\n    case 'small':\n      return 1;\n    case 'medium':\n      return 2;\n    case 'large':\n      return 4;\n    default:\n      return 2;\n  }\n};\n\ninterface QuiltedGridProps<T extends IImageElement | IVideoElement> {\n  elements: T[];\n  renderElement: ({ element }: { element: T }) => JSX.Element | null;\n  className?: string;\n}\n\nconst QuiltedGrid = <T extends IImageElement | IVideoElement>({\n  elements,\n  renderElement: Renderer,\n  className\n}: QuiltedGridProps<T>) => {\n  // If there's only one element, use a simpler layout\n  if (elements.length === 1) {\n    const element = elements[0];\n    const size = sizeToUnit(element);\n\n    return (\n      <div\n        className={cn(\n          'w-full',\n          // Adjust max-width based on size\n          size === 1\n            ? 'max-w-[150px]'\n            : size === 2\n            ? 'max-w-[300px]'\n            : 'max-w-[600px]',\n          className\n        )}\n      >\n        <Renderer element={element} />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        'grid grid-cols-4 gap-2 w-full max-w-[600px]',\n        'transform-gpu',\n        className\n      )}\n    >\n      {elements.map((element, i) => {\n        const cols = sizeToUnit(element);\n        const rows = sizeToUnit(element);\n\n        return (\n          <div\n            key={i}\n            className={cn(\n              'relative',\n              cols === 1\n                ? 'col-span-1'\n                : cols === 2\n                ? 'col-span-2'\n                : 'col-span-4',\n              rows === 1\n                ? 'row-span-1'\n                : rows === 2\n                ? 'row-span-2'\n                : 'row-span-4'\n            )}\n          >\n            <Renderer element={element} />\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n\nexport { QuiltedGrid };\n"
  },
  {
    "path": "frontend/src/components/ReadOnlyThread.tsx",
    "content": "import { MessageContext } from '@/contexts/MessageContext';\nimport { useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useLocation, useNavigate } from 'react-router-dom';\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  IAction,\n  IFeedback,\n  IMessageElement,\n  IStep,\n  IThread,\n  nestMessages,\n  sessionIdState,\n  sideViewState,\n  useApi,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';\n\nimport { ErrorBoundary } from './ErrorBoundary';\nimport { Loader } from './Loader';\nimport { Messages } from './chat/Messages';\n\ntype Props = {\n  id: string;\n};\n\nconst ReadOnlyThread = ({ id }: Props) => {\n  const { config } = useConfig();\n  const location = useLocation();\n  const isSharedRoute = location.pathname.startsWith('/share/');\n  const {\n    data: thread,\n    error: threadError,\n    isLoading\n  } = useApi<IThread>(\n    id\n      ? isSharedRoute\n        ? `/project/share/${id}`\n        : `/project/thread/${id}`\n      : null,\n    {\n      revalidateOnFocus: false\n    }\n  );\n  const navigate = useNavigate();\n  const setSideView = useSetRecoilState(sideViewState);\n  const [steps, setSteps] = useState<IStep[]>([]);\n  const apiClient = useContext(ChainlitContext);\n  const { t } = useTranslation();\n  const layoutMaxWidth = useLayoutMaxWidth();\n  const sessionId = useRecoilValue(sessionIdState);\n\n  useEffect(() => {\n    if (!thread) {\n      setSteps([]);\n      return;\n    }\n    setSteps(thread.steps);\n  }, [thread]);\n\n  useEffect(() => {\n    if (threadError) {\n      navigate('/');\n      toast.error('Failed to load thread: ' + threadError.message);\n    }\n  }, [threadError]);\n\n  const onFeedbackUpdated = useCallback(\n    async (message: IStep, onSuccess: () => void, feedback: IFeedback) => {\n      toast.promise(apiClient.setFeedback(feedback, sessionId), {\n        loading: 'Updating',\n        success: (res) => {\n          setSteps((prev) =>\n            prev.map((step) => {\n              if (step.id === message.id) {\n                return {\n                  ...step,\n                  feedback: {\n                    ...feedback,\n                    id: res.feedbackId\n                  }\n                };\n              }\n              return step;\n            })\n          );\n\n          onSuccess();\n          return 'Feedback updated!';\n        },\n        error: (err) => {\n          return <span>{err.message}</span>;\n        }\n      });\n    },\n    [setSteps]\n  );\n\n  const onFeedbackDeleted = useCallback(\n    async (message: IStep, onSuccess: () => void, feedbackId: string) => {\n      toast.promise(apiClient.deleteFeedback(feedbackId), {\n        loading: t('chat.messages.feedback.status.updating'),\n        success: () => {\n          setSteps((prev) =>\n            prev.map((step) => {\n              if (step.id === message.id) {\n                return {\n                  ...step,\n                  feedback: undefined\n                };\n              }\n              return step;\n            })\n          );\n\n          onSuccess();\n          return t('chat.messages.feedback.status.updated');\n        },\n        error: (err) => {\n          return <span>{err.message}</span>;\n        }\n      });\n    },\n    [setSteps]\n  );\n\n  const onElementRefClick = useCallback(\n    (element: IMessageElement) => {\n      if (element.display === 'side') {\n        setSideView({ title: element.name, elements: [element] });\n        return;\n      }\n\n      let path = `/element/${element.id}`;\n\n      if (element.threadId) {\n        path += `?thread=${element.threadId}`;\n      }\n\n      return navigate(element.display === 'page' ? path : '#');\n    },\n    [setSideView, navigate]\n  );\n\n  const onError = useCallback((error: string) => toast.error(error), [toast]);\n\n  const elements = thread?.elements || [];\n  const actions: IAction[] = [];\n  const messages = nestMessages(steps);\n\n  const memoizedContext = useMemo(() => {\n    return {\n      allowHtml: config?.features?.unsafe_allow_html,\n      latex: config?.features?.latex,\n      renderMarkdown: config?.features?.user_message_markdown,\n      editable: false,\n      loading: false,\n      showFeedbackButtons: !!config?.dataPersistence,\n      uiName: config?.ui?.name || '',\n      cot: config?.ui?.cot || 'hidden',\n      onElementRefClick,\n      onError,\n      onFeedbackUpdated,\n      onFeedbackDeleted\n    };\n  }, [\n    config?.ui?.name,\n    config?.ui?.cot,\n    config?.features?.unsafe_allow_html,\n    config?.features?.user_message_markdown,\n    onElementRefClick,\n    onError,\n    onFeedbackUpdated,\n    onFeedbackDeleted\n  ]);\n\n  if (!isSharedRoute && isLoading) {\n    return (\n      <div className=\"flex flex-col h-full w-full items-center justify-center\">\n        <Loader className=\"!size-6\" />\n      </div>\n    );\n  }\n\n  if (!isSharedRoute && !thread) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex w-full flex-col flex-grow relative overflow-y-auto\">\n      <ErrorBoundary>\n        <MessageContext.Provider value={memoizedContext}>\n          <div\n            className=\"flex flex-col mx-auto w-full flex-grow p-4\"\n            style={{\n              maxWidth: layoutMaxWidth\n            }}\n          >\n            <Messages\n              indent={0}\n              messages={messages}\n              elements={elements as any}\n              actions={actions}\n            />\n          </div>\n        </MessageContext.Provider>\n      </ErrorBoundary>\n    </div>\n  );\n};\n\nexport { ReadOnlyThread };\n"
  },
  {
    "path": "frontend/src/components/Tasklist/Task.tsx",
    "content": "import { Markdown } from '@/components/Markdown';\n\nimport { TaskStatusIcon } from './TaskStatusIcon';\n\nexport interface ITask {\n  title: string;\n  status: 'ready' | 'running' | 'done' | 'failed';\n  forId?: string;\n}\n\nexport interface ITaskList {\n  status: 'ready' | 'running' | 'done';\n  tasks: ITask[];\n}\n\ninterface TaskProps {\n  index: number;\n  task: ITask;\n  allowHtml?: boolean;\n  latex?: boolean;\n}\n\nexport const Task = ({ index, task, allowHtml, latex }: TaskProps) => {\n  const statusStyles = {\n    ready: '',\n    running: 'font-semibold',\n    done: 'text-muted-foreground',\n    failed: 'text-muted-foreground'\n  };\n\n  const handleClick = () => {\n    if (task.forId) {\n      const parent = document.getElementById(`step-${task.forId}`);\n      if (parent) {\n        // Find the child div below the main step container\n        const child = parent.querySelector('div');\n        if (child) {\n          child.classList.add('bg-card', 'rounded');\n          parent.scrollIntoView({\n            behavior: 'smooth',\n            block: 'start',\n            inline: 'start'\n          });\n          setTimeout(() => {\n            child.classList.remove('bg-card', 'rounded');\n          }, 600); // 2 blinks at 0.3s each\n        }\n      }\n    }\n  };\n\n  return (\n    <div className={`task task-status-${task.status}`}>\n      <div\n        className={`w-full grid grid-cols-[auto_auto_1fr] items-start gap-1.5 font-medium py-0.5 px-1 text-sm leading-tight ${\n          statusStyles[task.status]\n        } ${task.forId ? 'cursor-pointer' : 'cursor-default'}`}\n        onClick={handleClick}\n      >\n        <div className=\"text-xs text-muted-foreground text-right pr-1 pt-[1px]\">\n          {index}\n        </div>\n        <div className=\"flex items-start pt-[1px]\">\n          <TaskStatusIcon status={task.status} />\n        </div>\n        <div className=\"min-w-0\">\n          <Markdown\n            allowHtml={allowHtml}\n            latex={latex}\n            className=\"max-w-none prose-sm text-left break-words [&_p]:m-0 [&_p]:leading-snug [&_div]:leading-snug [&_div]:mt-0 [&_strong]:font-semibold\"\n          >\n            {task.title}\n          </Markdown>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/Tasklist/TaskStatusIcon.tsx",
    "content": "import { Check, Dot, X } from 'lucide-react';\n\nimport { Loader } from '@/components/Loader';\n\nimport type { ITask } from './Task';\n\nexport const TaskStatusIcon = ({ status }: { status: ITask['status'] }) => {\n  if (status === 'running') {\n    return <Loader className=\"!size-5\" />;\n  }\n\n  return (\n    <>\n      {status === 'done' && (\n        <Check className=\"!size-4 text-green-500 mt-[1px]\" />\n      )}\n      {status === 'ready' && <Dot className=\"!size-4 mt-[1px]\" />}\n      {status === 'failed' && <X className=\"!size-4 text-red-500 mt-[1px]\" />}\n    </>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/Tasklist/index.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport useSWR from 'swr';\n\nimport { useChatData, useConfig } from '@chainlit/react-client';\n\nimport { Badge } from '@/components/ui/badge';\nimport { Card, CardContent, CardHeader } from '@/components/ui/card';\n\nimport { ITaskList, Task } from './Task';\n\ninterface HeaderProps {\n  status: string;\n}\n\nconst fetcher = (url: string) =>\n  fetch(url, { credentials: 'include' }).then((r) => r.json());\n\nconst Header = ({ status }: HeaderProps) => {\n  return (\n    <CardHeader className=\"flex flex-row items-center justify-between gap-2 p-3\">\n      <div className=\"font-semibold\">Tasks</div>\n      <Badge variant=\"secondary\">{status || '?'}</Badge>\n    </CardHeader>\n  );\n};\n\ninterface TaskListProps {\n  isMobile: boolean;\n  isCopilot?: boolean;\n}\n\nconst TaskList = ({ isMobile, isCopilot }: TaskListProps) => {\n  const { tasklists } = useChatData();\n  const tasklist = tasklists[tasklists.length - 1];\n  const { config } = useConfig();\n\n  const allowHtml = config?.features?.unsafe_allow_html;\n  const latex = config?.features?.latex;\n\n  const { error, data, isLoading } = useSWR<ITaskList>(tasklist?.url, fetcher, {\n    keepPreviousData: true\n  });\n\n  if (!tasklist?.url) return null;\n\n  if (isLoading && !data) {\n    return null;\n  }\n\n  if (error) {\n    return null;\n  }\n\n  const content = data as ITaskList;\n  if (!content) return null;\n\n  const tasks = content.tasks;\n\n  if (isMobile) {\n    // Get the first running or ready task, or the latest task\n    let highlightedTaskIndex = tasks.length - 1;\n    for (let i = 0; i < tasks.length; i++) {\n      if (tasks[i].status === 'running' || tasks[i].status === 'ready') {\n        highlightedTaskIndex = i;\n        break;\n      }\n    }\n    const highlightedTask = tasks?.[highlightedTaskIndex];\n\n    return (\n      <aside\n        className={cn('w-full tasklist-mobile', !isCopilot && 'md:hidden')}\n      >\n        <Card>\n          <Header status={content.status} />\n          {highlightedTask && (\n            <CardContent className=\"p-2.5\">\n              <Task\n                index={highlightedTaskIndex + 1}\n                task={highlightedTask}\n                allowHtml={allowHtml}\n                latex={latex}\n              />\n            </CardContent>\n          )}\n        </Card>\n      </aside>\n    );\n  }\n\n  return (\n    <aside className=\"hidden tasklist max-w-[21rem] flex-grow md:block overflow-y-auto mr-3 mb-3\">\n      <Card className=\"overflow-y-auto h-full\">\n        <Header status={content?.status} />\n        <CardContent className=\"flex flex-col gap-1 p-2.5\">\n          {tasks?.map((task, index) => (\n            <Task\n              key={index}\n              index={index + 1}\n              task={task}\n              allowHtml={allowHtml}\n              latex={latex}\n            />\n          ))}\n        </CardContent>\n      </Card>\n    </aside>\n  );\n};\n\nexport { TaskList };\n"
  },
  {
    "path": "frontend/src/components/ThemeProvider.tsx",
    "content": "import { createContext, useContext, useEffect, useState } from 'react';\n\ntype Theme = 'dark' | 'light' | 'system';\n\ntype ThemeProviderProps = {\n  children: React.ReactNode;\n  defaultTheme?: Theme;\n  storageKey?: string;\n};\n\ntype ThemeProviderState = {\n  theme: Theme;\n  setTheme: (theme: Theme) => void;\n};\n\nconst initialState: ThemeProviderState = {\n  theme: 'system',\n  setTheme: () => null\n};\n\nconst ThemeProviderContext = createContext<ThemeProviderState>(initialState);\n\nfunction applyThemeVariables(variant: 'dark' | 'light') {\n  if (!window.theme) return;\n\n  const variables = window.theme[variant];\n  if (!variables) return;\n\n  const root = window.document.documentElement;\n\n  // Apply new theme variables\n  Object.entries(variables).forEach(([key, value]) => {\n    root.style.setProperty(key, value);\n  });\n}\n\nexport function ThemeProvider({\n  children,\n  defaultTheme = 'system',\n  storageKey = 'vite-ui-theme',\n  ...props\n}: ThemeProviderProps) {\n  const [theme, setTheme] = useState<Theme>(\n    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme\n  );\n  useEffect(() => {\n    const root = window.document.documentElement;\n\n    root.classList.remove('light', 'dark');\n\n    if (theme === 'system') {\n      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')\n        .matches\n        ? 'dark'\n        : 'light';\n\n      root.classList.add(systemTheme);\n      applyThemeVariables(systemTheme);\n      return;\n    } else {\n      applyThemeVariables(theme);\n    }\n\n    root.classList.add(theme);\n  }, [theme]);\n\n  const value = {\n    theme,\n    setTheme: (theme: Theme) => {\n      localStorage.setItem(storageKey, theme);\n      setTheme(theme);\n    }\n  };\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n\nexport const useTheme = () => {\n  const context = useContext(ThemeProviderContext);\n\n  if (context === undefined)\n    throw new Error('useTheme must be used within a ThemeProvider');\n\n  const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches\n    ? 'dark'\n    : 'light';\n\n  const variant = context.theme === 'system' ? systemTheme : context.theme;\n\n  return { ...context, variant };\n};\n"
  },
  {
    "path": "frontend/src/components/WaterMark.tsx",
    "content": "import { Markdown } from '@/components/Markdown';\nimport { useTranslation } from '@/components/i18n/Translator';\n\nexport default function WaterMark() {\n  const { t } = useTranslation();\n\n  return (\n    <div\n      className=\"watermark\"\n      style={{\n        display: 'flex',\n        alignItems: 'center',\n        textDecoration: 'none'\n      }}\n    >\n      <Markdown className=\"[&_p]:m-0 [&_p]:leading-snug [&_div]:leading-snug [&_div]:mt-0 [&_strong]:font-semibold text-xs text-muted-foreground\">\n        {t('chat.watermark')}\n      </Markdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Footer.tsx",
    "content": "import { cn, hasMessage } from '@/lib/utils';\nimport { MutableRefObject } from 'react';\n\nimport { FileSpec, useChatMessages } from '@chainlit/react-client';\n\nimport WaterMark from '@/components/WaterMark';\n\nimport MessageComposer from './MessageComposer';\n\ninterface Props {\n  fileSpec: FileSpec;\n  onFileUpload: (payload: File[]) => void;\n  onFileUploadError: (error: string) => void;\n  autoScrollRef: MutableRefObject<boolean>;\n  showIfEmptyThread?: boolean;\n}\n\nexport default function ChatFooter({ showIfEmptyThread, ...props }: Props) {\n  const { messages } = useChatMessages();\n  if (!hasMessage(messages) && !showIfEmptyThread) return null;\n\n  return (\n    <div className={cn('relative flex flex-col items-center gap-2 w-full')}>\n      <MessageComposer {...props} />\n      <WaterMark />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Attachment.tsx",
    "content": "import React, { useEffect, useMemo } from 'react';\nimport { DefaultExtensionType, FileIcon, defaultStyles } from 'react-file-icon';\n\nimport { Card } from '@/components/ui/card';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface AttachmentProps {\n  name: string;\n  mime: string;\n  children?: React.ReactNode;\n  file?: File;\n}\n\nconst Attachment: React.FC<AttachmentProps> = ({\n  name,\n  mime,\n  children,\n  file\n}) => {\n  const isImage = useMemo(() => mime.startsWith('image/'), [mime]);\n  const imageUrl = useMemo(() => {\n    if (isImage && file) {\n      return URL.createObjectURL(file);\n    }\n    return undefined;\n  }, [isImage, file]);\n\n  // Cleanup Object URL on unmount or when imageUrl changes\n  useEffect(() => {\n    return () => {\n      if (imageUrl) {\n        URL.revokeObjectURL(imageUrl);\n      }\n    };\n  }, [imageUrl]);\n\n  let extension: DefaultExtensionType;\n  if (name.includes('.')) {\n    extension = name.split('.').pop()!.toLowerCase() as DefaultExtensionType;\n  } else {\n    extension = mime\n      ? ((mime.split('/').pop() || 'txt') as DefaultExtensionType)\n      : ('txt' as DefaultExtensionType);\n  }\n\n  if (isImage && imageUrl) {\n    return (\n      <TooltipProvider delayDuration={100}>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div className=\"relative h-[58px] w-[58px]\">\n              {children}\n              <Card className=\"h-full p-1 flex items-center justify-center rounded-lg border overflow-hidden\">\n                <img\n                  src={imageUrl}\n                  alt={name}\n                  className=\"h-full w-full object-cover\"\n                />\n              </Card>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{name}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  }\n\n  return (\n    <TooltipProvider delayDuration={100}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <div className=\"relative h-[58px]\">\n            {children}\n            <Card className=\"h-full p-2 flex flex-row items-center gap-3 rounded-lg w-full max-w-[200px] border\">\n              <div className=\"w-10\">\n                <FileIcon {...defaultStyles[extension]} extension={extension} />\n              </div>\n              <span className=\"truncate w-[80%] font-medium text-sm font-medium\">\n                {name}\n              </span>\n            </Card>\n          </div>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>{name}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nexport { Attachment };\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Attachments.tsx",
    "content": "import { X } from 'lucide-react';\nimport React from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport { useTranslation } from '@/components/i18n/Translator';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { attachmentsState } from '@/state/chat';\n\nimport { Attachment } from './Attachment';\n\nconst CircularProgressButton = ({\n  progress,\n  onClick,\n  children\n}: {\n  progress: number;\n  onClick: () => void;\n  children: React.ReactNode;\n}) => {\n  const size = 24; // 6 * 4 (w-6 = 1.5rem = 24px)\n  const strokeWidth = 2;\n  const radius = (size - strokeWidth) / 2;\n  const circumference = 2 * Math.PI * radius;\n  const strokeDashoffset = circumference - (progress / 100) * circumference;\n\n  return (\n    <div className=\"relative inline-flex items-center justify-center\">\n      <svg\n        className=\"absolute\"\n        width={size}\n        height={size}\n        viewBox={`0 0 ${size} ${size}`}\n      >\n        <circle\n          className=\"text-muted-foreground/20\"\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={strokeWidth}\n          stroke=\"currentColor\"\n        />\n        <circle\n          className=\"text-primary transition-all duration-300 ease-in-out\"\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={strokeWidth}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeDasharray={circumference}\n          strokeDashoffset={strokeDashoffset}\n          transform={`rotate(-90 ${size / 2} ${size / 2})`}\n        />\n      </svg>\n      <Button\n        size=\"icon\"\n        className=\"w-6 h-6 rounded-full bg-card hover:bg-card text-foreground\"\n        onClick={onClick}\n      >\n        {children}\n      </Button>\n    </div>\n  );\n};\nconst Attachments = () => {\n  const { t } = useTranslation();\n  const attachments = useRecoilValue(attachmentsState);\n\n  if (attachments.length === 0) return null;\n\n  return (\n    <div id=\"attachments\" className=\"flex flex-row flex-wrap gap-4 w-fit\">\n      {attachments.map((attachment) => {\n        const showProgress = !attachment.uploaded && attachment.cancel;\n\n        const progress = showProgress ? (\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <div className=\"absolute -right-2 -top-2\">\n                  <CircularProgressButton\n                    progress={attachment.uploadProgress || 0}\n                    onClick={() => attachment.cancel?.()}\n                  >\n                    <X className=\"!size-3\" />\n                  </CircularProgressButton>\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                {t('chat.fileUpload.actions.cancelUpload')}\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        ) : null;\n\n        const remove =\n          !showProgress && attachment.remove ? (\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <div className=\"absolute -right-2 -top-2\">\n                    <Button\n                      size=\"icon\"\n                      className=\"w-6 h-6 shadow-sm rounded-full border-4 bg-card hover:bg-card text-foreground light:border-muted\"\n                      onClick={attachment.remove}\n                    >\n                      <X className=\"!size-3\" />\n                    </Button>\n                  </div>\n                </TooltipTrigger>\n                <TooltipContent>\n                  {t('chat.fileUpload.actions.removeAttachment')}\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          ) : null;\n\n        return (\n          <Attachment\n            key={attachment.id}\n            name={attachment.name}\n            mime={attachment.type}\n            file={attachment.file}\n          >\n            {progress}\n            {remove}\n          </Attachment>\n        );\n      })}\n    </div>\n  );\n};\n\nexport { Attachments };\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/CommandButtons.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { X } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport { ICommand, commandsState } from '@chainlit/react-client';\n\nimport Icon from '@/components/Icon';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface Props {\n  disabled?: boolean;\n  selectedCommandId?: string;\n  onCommandSelect: (command?: ICommand) => void;\n}\n\ninterface AnimatedCommandButtonProps {\n  command: ICommand;\n  isSelected: boolean;\n  disabled: boolean;\n  onCommandSelect: (command?: ICommand) => void;\n  index: number;\n}\n\nconst AnimatedCommandButton = ({\n  command,\n  isSelected,\n  disabled,\n  onCommandSelect,\n  index\n}: AnimatedCommandButtonProps) => {\n  const [isAnimating, setIsAnimating] = useState(false);\n  const [hasInitialized, setHasInitialized] = useState(false);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    // Prevent initial animation on mount\n    const timer = setTimeout(() => setHasInitialized(true), 100);\n    return () => clearTimeout(timer);\n  }, []);\n\n  const handleClick = () => {\n    setIsAnimating(true);\n    onCommandSelect(isSelected ? undefined : command);\n    setTimeout(() => setIsAnimating(false), 300);\n  };\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <Button\n          ref={buttonRef}\n          id={`command-${command.id}`}\n          variant=\"ghost\"\n          disabled={disabled}\n          className={cn(\n            'command-button relative p-2 h-9 text-[13px] font-medium rounded-full',\n            'transition-all duration-300 ease-out',\n            'transform-gpu overflow-hidden',\n            // Same hover background for both selected and unselected\n            'hover:bg-muted',\n            // Selected state: blue text color that persists on hover\n            isSelected && 'text-command hover:text-command',\n            isAnimating && 'animate-bounce-subtle',\n            !hasInitialized && 'opacity-0',\n            hasInitialized && 'opacity-100',\n            // Underline animation for selected state\n            isSelected &&\n              'after:content-[\"\"] after:absolute after:bottom-[-2px] after:left-1/2 after:-translate-x-1/2 after:w-[30%] after:h-[2px] after:bg-command after:rounded-[1px] after:animate-expand-width'\n          )}\n          onClick={handleClick}\n          style={{\n            animationDelay: hasInitialized ? '0ms' : `${index * 50}ms`\n          }}\n        >\n          <div className=\"flex items-center\">\n            <Icon\n              name={command.icon}\n              className={cn(\n                '!h-5 !w-5 transition-colors duration-200',\n                isSelected && 'text-command'\n              )}\n            />\n            <span\n              className={cn(\n                'ml-1.5 transition-all duration-300',\n                isSelected\n                  ? 'max-w-[200px] overflow-visible'\n                  : 'max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap max-sm:hidden'\n              )}\n            >\n              {command.id}\n            </span>\n            <div\n              className={cn(\n                'ml-1 transition-all duration-300 flex items-center',\n                isSelected ? 'w-4 opacity-60' : 'w-0 opacity-0'\n              )}\n            >\n              <X className=\"!size-4 text-command\" />\n            </div>\n          </div>\n        </Button>\n      </TooltipTrigger>\n      <TooltipContent>\n        <p>{command.description}</p>\n      </TooltipContent>\n    </Tooltip>\n  );\n};\n\nexport const CommandButtons = ({\n  disabled = false,\n  selectedCommandId,\n  onCommandSelect\n}: Props) => {\n  const commands = useRecoilValue(commandsState);\n  const commandButtons = commands.filter((c) => !!c.button);\n\n  // Find the selected command if it's not a button command\n  const selectedCommand = commands.find(\n    (c) => c.id === selectedCommandId && !c.button\n  );\n\n  // If no button commands and no selected non-button command, don't render\n  if (!commandButtons.length && !selectedCommand) return null;\n\n  return (\n    <div className=\"flex gap-1 ml-1 flex-wrap command-buttons-container\">\n      <TooltipProvider>\n        {/* Show selected non-button command as a button */}\n        {selectedCommand && (\n          <AnimatedCommandButton\n            key={selectedCommand.id}\n            command={selectedCommand}\n            isSelected={true}\n            disabled={disabled}\n            onCommandSelect={onCommandSelect}\n            index={0}\n          />\n        )}\n\n        {/* Show button commands */}\n        {commandButtons.map((command, index) => (\n          <AnimatedCommandButton\n            key={command.id}\n            command={command}\n            isSelected={selectedCommandId === command.id}\n            disabled={disabled}\n            onCommandSelect={onCommandSelect}\n            index={selectedCommand ? index + 1 : index}\n          />\n        ))}\n      </TooltipProvider>\n    </div>\n  );\n};\n\nexport default CommandButtons;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger\n} from '@radix-ui/react-popover';\nimport { every } from 'lodash';\nimport { Settings2 } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport { ICommand, commandsState } from '@chainlit/react-client';\n\nimport Icon from '@/components/Icon';\nimport { Button } from '@/components/ui/button';\nimport {\n  Command,\n  CommandGroup,\n  CommandItemAnimated,\n  CommandListScrollable\n} from '@/components/ui/command';\nimport {\n  TOOLTIP_DELAY_MS,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { useTranslation } from 'components/i18n/Translator';\n\nimport { useCommandNavigation } from '@/hooks/useCommandNavigation';\n\ninterface Props {\n  disabled?: boolean;\n  selectedCommandId?: string;\n  onCommandSelect: (command: ICommand) => void;\n}\n\nexport const CommandPopoverButton = ({\n  disabled = false,\n  selectedCommandId,\n  onCommandSelect\n}: Props) => {\n  const { t } = useTranslation();\n  const commands = useRecoilValue(commandsState);\n  const [open, setOpen] = useState(false);\n  const [isAnimating, setIsAnimating] = useState(false);\n  const [tooltipOpen, setTooltipOpen] = useState(false);\n  const hoverTimerRef = useRef<number | null>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const allButtons = every(commands.map((c) => !!c.button));\n\n  // Check if there's a selected non-button command\n  const hasSelectedNonButtonCommand = commands.some(\n    (c) => c.id === selectedCommandId && !c.button\n  );\n\n  const nonButtonCommands = commands.filter((c) => !c.button);\n\n  // Handle direct command selection (for mouse clicks)\n  const handleCommandSelect = (command: ICommand) => {\n    onCommandSelect(command);\n    setOpen(false);\n    cancelTooltipOpen();\n  };\n\n  const { selectedIndex, handleMouseMove, handleMouseLeave, handleKeyDown } =\n    useCommandNavigation({\n      items: nonButtonCommands,\n      isOpen: open,\n      onSelect: handleCommandSelect, // This will be used for keyboard selection\n      onClose: () => {\n        setOpen(false);\n        cancelTooltipOpen();\n        buttonRef.current?.focus();\n      }\n    });\n\n  // Handle animation when selection changes\n  useEffect(() => {\n    if (hasSelectedNonButtonCommand) {\n      setIsAnimating(true);\n      const timer = setTimeout(() => setIsAnimating(false), 300);\n      return () => clearTimeout(timer);\n    }\n  }, [hasSelectedNonButtonCommand]);\n\n  // Ensure timers are cleared on unmount\n  useEffect(() => {\n    return () => {\n      if (hoverTimerRef.current) {\n        clearTimeout(hoverTimerRef.current);\n        hoverTimerRef.current = null;\n      }\n    };\n  }, []);\n\n  // Reset selection when opening and never show tooltip while popover is open\n  useEffect(() => {\n    if (open) {\n      if (hoverTimerRef.current) {\n        clearTimeout(hoverTimerRef.current);\n        hoverTimerRef.current = null;\n      }\n      setTooltipOpen(false);\n    }\n  }, [open]);\n\n  const scheduleTooltipOpen = () => {\n    if (disabled) return;\n    if (hoverTimerRef.current) {\n      clearTimeout(hoverTimerRef.current);\n    }\n    hoverTimerRef.current = window.setTimeout(() => {\n      setTooltipOpen(true);\n    }, TOOLTIP_DELAY_MS);\n  };\n\n  const cancelTooltipOpen = () => {\n    if (hoverTimerRef.current) {\n      clearTimeout(hoverTimerRef.current);\n      hoverTimerRef.current = null;\n    }\n    setTooltipOpen(false);\n  };\n\n  if (!commands.length || allButtons) return null;\n\n  return (\n    <div\n      className={cn(\n        'command-popover-wrapper',\n        'transition-all duration-300 ease-out',\n        isAnimating && 'animate-command-shift'\n      )}\n    >\n      <Popover\n        open={open}\n        onOpenChange={(v) => {\n          setOpen(v);\n          if (v) cancelTooltipOpen(); // suppress tooltip while popover is open\n        }}\n      >\n        <TooltipProvider>\n          {/* Controlled tooltip so it only opens after our delay and never on focus */}\n          <Tooltip open={!open && tooltipOpen}>\n            <TooltipTrigger asChild>\n              <PopoverTrigger asChild>\n                <Button\n                  ref={buttonRef}\n                  id=\"command-button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  aria-haspopup=\"menu\"\n                  aria-expanded={open}\n                  aria-controls=\"command-popover\"\n                  className={cn(\n                    'flex items-center h-9 rounded-full font-medium text-[13px]',\n                    'hover:bg-muted hover:dark:bg-muted transition-all duration-200 transition-width-padding',\n                    'focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',\n                    open && 'bg-muted/50',\n                    hasSelectedNonButtonCommand ? 'min-w-[36px] px-0 gap-0' : 'px-3 gap-1.5'\n                  )}\n                  disabled={disabled}\n                  onMouseEnter={scheduleTooltipOpen}\n                  onMouseLeave={cancelTooltipOpen}\n                >\n                  <Settings2\n                    className={cn(\n                      '!size-5 transition-transform duration-200',\n                      open && 'rotate-45'\n                    )}\n                  />\n                  {!hasSelectedNonButtonCommand && (\n                    <span className=\"overflow-hidden transition-all duration-300 opacity-100 w-auto max-w-[100px]\">\n                      {t('chat.commands.button')}\n                    </span>\n                  )}\n                </Button>\n              </PopoverTrigger>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>\n                {hasSelectedNonButtonCommand\n                  ? t('chat.commands.changeTool')\n                  : t('chat.commands.availableTools')}\n              </p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n\n        <PopoverContent\n          id=\"command-popover\"\n          align=\"start\"\n          sideOffset={12}\n          data-popover-content\n          tabIndex={0}\n          className={cn(\n            'p-2 rounded-lg border shadow-md bg-background',\n            'animate-in fade-in-0 zoom-in-95 duration-200',\n            'focus:outline-none'\n          )}\n          onKeyDown={handleKeyDown}\n          onMouseLeave={handleMouseLeave}\n        >\n          <Command className=\"overflow-hidden bg-transparent\">\n            <CommandListScrollable maxItems={5} className=\"custom-scrollbar\">\n              <CommandGroup className=\"p-0\">\n                {nonButtonCommands.map((command, index) => (\n                  <CommandItemAnimated\n                    key={command.id}\n                    index={index}\n                    isSelected={index === selectedIndex}\n                    onMouseMove={() => handleMouseMove(index)}\n                    onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks\n                    className=\"space-x-2\"\n                  >\n                    <Icon\n                      name={command.icon}\n                      className={cn(\n                        '!size-5 text-muted-foreground transition-transform duration-150',\n                        index === selectedIndex && 'scale-110'\n                      )}\n                    />\n                    <div className=\"flex-1\">\n                      <div className=\"font-medium\">{command.id}</div>\n                      <div className=\"text-sm text-muted-foreground\">\n                        {command.description}\n                      </div>\n                    </div>\n                  </CommandItemAnimated>\n                ))}\n              </CommandGroup>\n            </CommandListScrollable>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n};\n\nexport default CommandPopoverButton;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/FavoriteButton.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger\n} from '@radix-ui/react-popover';\nimport { Star, Trash } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport {\n  favoriteMessagesState,\n  useChatInteract,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { useTranslation } from '@/components/i18n/Translator';\nimport { Button } from '@/components/ui/button';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandListScrollable\n} from '@/components/ui/command';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nconst TOOLTIP_DELAY_MS = 700;\n\ninterface Props {\n  disabled?: boolean;\n  onSelect: (content: string) => void;\n}\n\nexport const FavoriteButton = ({ disabled = false, onSelect }: Props) => {\n  const favorites = useRecoilValue(favoriteMessagesState);\n  const { toggleMessageFavorite } = useChatInteract();\n  const { config } = useConfig();\n  const { t } = useTranslation();\n\n  const [open, setOpen] = useState(false);\n  const [tooltipOpen, setTooltipOpen] = useState(false);\n  const hoverTimerRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    return () => {\n      if (hoverTimerRef.current) {\n        clearTimeout(hoverTimerRef.current);\n      }\n    };\n  }, []);\n\n  useEffect(() => {\n    if (open) {\n      cancelTooltipOpen();\n    }\n  }, [open]);\n\n  const scheduleTooltipOpen = () => {\n    if (disabled || open) return;\n    if (hoverTimerRef.current) {\n      clearTimeout(hoverTimerRef.current);\n    }\n    hoverTimerRef.current = window.setTimeout(() => {\n      setTooltipOpen(true);\n    }, TOOLTIP_DELAY_MS);\n  };\n\n  const cancelTooltipOpen = () => {\n    if (hoverTimerRef.current) {\n      clearTimeout(hoverTimerRef.current);\n      hoverTimerRef.current = null;\n    }\n    setTooltipOpen(false);\n  };\n\n  if (!config?.features?.favorites) return null;\n\n  return (\n    <div className={cn('favorite-popover-wrapper')}>\n      <Popover\n        open={open}\n        onOpenChange={(val) => {\n          setOpen(val);\n          if (val) cancelTooltipOpen();\n        }}\n      >\n        <TooltipProvider>\n          <Tooltip open={!open && tooltipOpen}>\n            <TooltipTrigger asChild>\n              <PopoverTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className={cn(\n                    'flex items-center h-9 px-3 rounded-full font-medium text-[13px] gap-1.5',\n                    'hover:bg-muted hover:dark:bg-muted transition-all duration-200',\n                    open && 'bg-muted/50'\n                  )}\n                  disabled={disabled}\n                  onMouseEnter={scheduleTooltipOpen}\n                  onMouseLeave={cancelTooltipOpen}\n                  onFocus={cancelTooltipOpen}\n                >\n                  <Star className=\"!size-5\" />\n                </Button>\n              </PopoverTrigger>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{t('chat.favorites.use')}</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n\n        <PopoverContent\n          align=\"start\"\n          sideOffset={12}\n          className=\"p-2 w-[300px] rounded-lg border shadow-md bg-background\"\n        >\n          <Command>\n            <CommandListScrollable className=\"max-h-[300px] custom-scrollbar\">\n              {favorites.length === 0 ? (\n                <CommandEmpty className=\"py-6 px-4\">\n                  <div className=\"flex flex-col items-center gap-2 text-center\">\n                    <p className=\"text-sm font-medium text-foreground\">\n                      {t('chat.favorites.empty.title')}\n                    </p>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {t('chat.favorites.empty.description')}\n                    </p>\n                  </div>\n                </CommandEmpty>\n              ) : (\n                <CommandGroup heading={t('chat.favorites.headline')}>\n                  {favorites.map((step) => (\n                    <CommandItem\n                      key={step.id}\n                      value={step.id}\n                      onSelect={() => {\n                        onSelect(step.output);\n                        setOpen(false);\n                        cancelTooltipOpen();\n                      }}\n                      className=\"cursor-pointer group\"\n                    >\n                      <div className=\"flex items-center justify-between gap-2 w-full overflow-hidden\">\n                        <div className=\"flex flex-col gap-1 overflow-hidden\">\n                          <span className=\"truncate text-sm\">\n                            {step.output}\n                          </span>\n                          <span className=\"text-xs text-muted-foreground\">\n                            {new Date(step.createdAt).toLocaleDateString()}\n                          </span>\n                        </div>\n                        <Button\n                          type=\"button\"\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          className=\"h-7 w-7 text-muted-foreground hover:text-foreground\"\n                          aria-label={t('chat.favorites.remove')}\n                          disabled={disabled}\n                          onPointerDown={(event) => event.stopPropagation()}\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            toggleMessageFavorite(step);\n                          }}\n                        >\n                          <Trash className=\"h-3.5 w-3.5\" />\n                        </Button>\n                      </div>\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              )}\n            </CommandListScrollable>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n};\n\nexport default FavoriteButton;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Input.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport React, {\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState\n} from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport { ICommand, commandsState } from '@chainlit/react-client';\n\nimport AutoResizeTextarea from '@/components/AutoResizeTextarea';\nimport Icon from '@/components/Icon';\nimport {\n  Command,\n  CommandGroup,\n  CommandItemAnimated,\n  CommandListScrollable\n} from '@/components/ui/command';\n\nimport { useCommandNavigation } from '@/hooks/useCommandNavigation';\n\ninterface Props {\n  id?: string;\n  className?: string;\n  autoFocus?: boolean;\n  placeholder?: string;\n  selectedCommand?: ICommand;\n  setSelectedCommand: (command: ICommand | undefined) => void;\n  onChange: (value: string) => void;\n  onPaste?: (event: any) => void;\n  onEnter?: () => void;\n}\n\nexport interface InputMethods {\n  reset: () => void;\n  setValueExtern: (value: string) => void;\n}\n\nconst Input = forwardRef<InputMethods, Props>(\n  (\n    {\n      placeholder,\n      id,\n      className,\n      autoFocus,\n      selectedCommand,\n      setSelectedCommand,\n      onChange,\n      onEnter,\n      onPaste\n    },\n    ref\n  ) => {\n    const commands = useRecoilValue(commandsState);\n    const [isComposing, setIsComposing] = useState(false);\n    const [showCommands, setShowCommands] = useState(false);\n    const [commandInput, setCommandInput] = useState('');\n    const [value, setValue] = useState('');\n    const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n    const normalizedInput = commandInput.toLowerCase().slice(1);\n\n    const filteredCommands = commands\n      .filter((command) => command.id.toLowerCase().includes(normalizedInput))\n      .sort((a, b) => {\n        const indexA = a.id.toLowerCase().indexOf(normalizedInput);\n        const indexB = b.id.toLowerCase().indexOf(normalizedInput);\n        return indexA - indexB;\n      });\n\n    const {\n      selectedIndex,\n      handleMouseMove,\n      handleMouseLeave,\n      handleKeyDown: navigationKeyDown\n    } = useCommandNavigation({\n      items: filteredCommands,\n      isOpen: showCommands,\n      onSelect: (command) => {\n        handleCommandSelect(command);\n      },\n      onClose: () => {\n        setShowCommands(false);\n        setCommandInput('');\n      }\n    });\n\n    const reset = () => {\n      setValue('');\n      if (!selectedCommand?.persistent) {\n        setSelectedCommand(undefined);\n      }\n      setCommandInput('');\n      setShowCommands(false);\n      onChange('');\n    };\n\n    useImperativeHandle(ref, () => ({\n      reset,\n      setValueExtern: (value: string) => {\n        setValue(value);\n        onChange(value);\n      }\n    }));\n\n    useEffect(() => {\n      if (textareaRef.current && autoFocus) {\n        textareaRef.current.focus();\n      }\n    }, [autoFocus]);\n\n    const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n      const newValue = e.target.value;\n      setValue(newValue);\n      onChange(newValue);\n\n      // Command detection for dropdown\n      const words = newValue.split(' ');\n      if (words.length === 1 && words[0].startsWith('/')) {\n        setShowCommands(true);\n        setCommandInput(words[0]);\n      } else {\n        setShowCommands(false);\n        setCommandInput('');\n      }\n    };\n\n    const handleCommandSelect = (command: ICommand) => {\n      setShowCommands(false);\n      setSelectedCommand(command);\n\n      // Remove the command text from the input\n      const newValue = value.replace(commandInput, '').trimStart();\n      setValue(newValue);\n      onChange(newValue);\n\n      setCommandInput('');\n\n      // Focus back on textarea\n      setTimeout(() => {\n        textareaRef.current?.focus();\n      }, 0);\n    };\n\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      // Handle command selection - check this FIRST before other key handling\n      if (showCommands && filteredCommands.length > 0) {\n        navigationKeyDown(e);\n        // If the navigation handled the key, don't process further\n        if (e.defaultPrevented) {\n          return;\n        }\n      }\n\n      // Handle regular enter only if command menu is not showing\n      if (\n        e.key === 'Enter' &&\n        !e.shiftKey &&\n        onEnter &&\n        !isComposing &&\n        !showCommands\n      ) {\n        e.preventDefault();\n        onEnter();\n      }\n    };\n\n    return (\n      <div className=\"relative w-full\">\n        <AutoResizeTextarea\n          ref={textareaRef}\n          id={id}\n          autoFocus={autoFocus}\n          value={value}\n          onChange={handleChange}\n          onKeyDown={handleKeyDown}\n          onPaste={onPaste}\n          onCompositionStart={() => setIsComposing(true)}\n          onCompositionEnd={() => setIsComposing(false)}\n          placeholder={placeholder}\n          className={cn(\n            'w-full resize-none bg-transparent placeholder:text-muted-foreground focus:outline-none',\n            className\n          )}\n          maxHeight={250}\n        />\n\n        {showCommands && filteredCommands.length > 0 && (\n          <div\n            className=\"absolute z-50 left-0 bottom-full mb-3 animate-slide-up\"\n            onMouseLeave={handleMouseLeave}\n          >\n            <Command className=\"rounded-lg border shadow-md bg-background\">\n              <CommandListScrollable maxItems={5} className=\"custom-scrollbar\">\n                <CommandGroup className=\"p-2\">\n                  {filteredCommands.map((command, index) => (\n                    <CommandItemAnimated\n                      key={command.id}\n                      index={index}\n                      isSelected={index === selectedIndex}\n                      onMouseMove={() => handleMouseMove(index)}\n                      onSelect={() => handleCommandSelect(command)}\n                      className=\"command-item space-x-2\"\n                    >\n                      <Icon\n                        name={command.icon}\n                        className={cn(\n                          '!size-5 text-muted-foreground transition-transform duration-150',\n                          index === selectedIndex && 'scale-110'\n                        )}\n                      />\n                      <div className=\"flex-1\">\n                        <div className=\"font-medium\">{command.id}</div>\n                        <div className=\"text-sm text-muted-foreground\">\n                          {command.description}\n                        </div>\n                      </div>\n                    </CommandItemAnimated>\n                  ))}\n                </CommandGroup>\n              </CommandListScrollable>\n            </Command>\n          </div>\n        )}\n      </div>\n    );\n  }\n);\n\nexport default Input;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Mcp/AddForm.tsx",
    "content": "import { useContext, useState } from 'react';\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  mcpState,\n  sessionIdState\n} from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@/components/ui/select';\nimport { Translator } from 'components/i18n';\n\ninterface McpAddFormProps {\n  onSuccess: () => void;\n  onCancel: () => void;\n  allowStdio?: boolean;\n  allowSse?: boolean;\n  allowHttp?: boolean;\n}\n\nexport const McpAddForm = ({\n  onSuccess,\n  onCancel,\n  allowStdio,\n  allowSse,\n  allowHttp\n}: McpAddFormProps) => {\n  const apiClient = useContext(ChainlitContext);\n  const sessionId = useRecoilValue(sessionIdState);\n  const setMcps = useSetRecoilState(mcpState);\n\n  const [serverName, setServerName] = useState('');\n  // Pick the first protocol enabled by the parent component.\n  const defaultType: 'stdio' | 'sse' | 'streamable-http' = allowStdio\n    ? 'stdio'\n    : allowSse\n    ? 'sse'\n    : allowHttp\n    ? 'streamable-http'\n    : 'stdio';\n\n  const [serverType, setServerType] = useState<\n    'stdio' | 'sse' | 'streamable-http'\n  >(defaultType);\n  const [serverUrl, setServerUrl] = useState('');\n  const [httpUrl, setHttpUrl] = useState('');\n  const [serverCommand, setServerCommand] = useState('');\n  const [headersInput, setHeadersInput] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Form validation function\n  const isFormValid = () => {\n    if (!serverName.trim()) return false;\n\n    if (serverType === 'stdio') {\n      return !!serverCommand.trim();\n    } else if (serverType === 'sse') {\n      return !!serverUrl.trim();\n    } else if (serverType === 'streamable-http') {\n      return !!httpUrl.trim();\n    }\n    return false;\n  };\n\n  const resetForm = () => {\n    setServerName('');\n    setServerType(defaultType);\n    setServerUrl('');\n    setServerCommand('');\n    setHttpUrl('');\n    setHeadersInput('');\n  };\n\n  const addMcp = () => {\n    setIsLoading(true);\n\n    // Helper to parse the optional headers JSON\n    let headersObj: Record<string, string> | undefined;\n    if (headersInput.trim()) {\n      try {\n        headersObj = JSON.parse(headersInput.trim());\n      } catch (_err) {\n        toast.error('Headers must be valid JSON');\n        setIsLoading(false);\n        return;\n      }\n    }\n\n    if (serverType === 'stdio') {\n      toast.promise(\n        apiClient\n          .connectStdioMCP(sessionId, serverName, serverCommand)\n          .then(async (resp: any) => {\n            const { success, mcp } = resp;\n            if (success && mcp) {\n              setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);\n            }\n            resetForm();\n            onSuccess();\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Adding MCP...',\n          success: () => 'MCP added!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    } else if (serverType === 'sse') {\n      toast.promise(\n        (apiClient as any)\n          .connectSseMCP(sessionId, serverName, serverUrl, headersObj)\n          .then(async (resp: any) => {\n            const { success, mcp } = resp;\n            if (success && mcp) {\n              setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);\n            }\n            resetForm();\n            onSuccess();\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Adding MCP...',\n          success: () => 'MCP added!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    } else if (serverType === 'streamable-http') {\n      toast.promise(\n        (apiClient as any)\n          .connectStreamableHttpMCP(sessionId, serverName, httpUrl, headersObj)\n          .then(async (resp: any) => {\n            const { success, mcp } = resp;\n            if (success && mcp) {\n              setMcps((prev) => [...prev, { ...mcp, status: 'connected' }]);\n            }\n            resetForm();\n            onSuccess();\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Adding MCP...',\n          success: () => 'MCP added!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    }\n  };\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-4\">\n        <div className=\"flex gap-2 w-full\">\n          <div className=\"flex flex-col flex-grow gap-2\">\n            <Label htmlFor=\"server-name\" className=\"text-foreground/70 text-sm\">\n              Name *\n            </Label>\n            <Input\n              id=\"server-name\"\n              placeholder=\"Example: Stripe\"\n              className=\"w-full bg-background text-foreground border-input\"\n              value={serverName}\n              onChange={(e) => setServerName(e.target.value)}\n              required\n              disabled={isLoading}\n            />\n          </div>\n\n          <div className=\"flex flex-col gap-2\">\n            <Label htmlFor=\"server-type\" className=\"text-foreground/70 text-sm\">\n              Type *\n            </Label>\n            <Select\n              value={serverType}\n              onValueChange={setServerType as any}\n              disabled={isLoading}\n            >\n              <SelectTrigger\n                id=\"server-type\"\n                className=\"w-full bg-background text-foreground border-input\"\n              >\n                <SelectValue placeholder=\"Type\" />\n              </SelectTrigger>\n              <SelectContent>\n                {allowSse ? <SelectItem value=\"sse\">sse</SelectItem> : null}\n                {allowStdio ? (\n                  <SelectItem value=\"stdio\">stdio</SelectItem>\n                ) : null}\n                {allowHttp ? (\n                  <SelectItem value=\"streamable-http\">\n                    streamable-http\n                  </SelectItem>\n                ) : null}\n              </SelectContent>\n            </Select>\n          </div>\n        </div>\n\n        <div className=\"flex flex-col gap-2\">\n          {serverType === 'stdio' && (\n            <>\n              <Label\n                htmlFor=\"server-command\"\n                className=\"text-foreground/70 text-sm\"\n              >\n                Command *\n              </Label>\n              <Input\n                id=\"server-command\"\n                placeholder=\"Example: npx -y @stripe/mcp --tools=all --api-key=YOUR_STRIPE_SECRET_KEY\"\n                className=\"w-full bg-background text-foreground border-input\"\n                value={serverCommand}\n                onChange={(e) => setServerCommand(e.target.value)}\n                required\n                disabled={isLoading}\n              />\n            </>\n          )}\n          {serverType === 'sse' && (\n            <>\n              <Label\n                htmlFor=\"server-url\"\n                className=\"text-foreground/70 text-sm\"\n              >\n                Server URL *\n              </Label>\n              <Input\n                id=\"server-url\"\n                placeholder=\"Example: http://localhost:5000\"\n                className=\"w-full bg-background text-foreground border-input\"\n                value={serverUrl}\n                onChange={(e) => setServerUrl(e.target.value)}\n                required\n                disabled={isLoading}\n              />\n            </>\n          )}\n          {serverType === 'streamable-http' && (\n            <>\n              <Label htmlFor=\"http-url\" className=\"text-foreground/70 text-sm\">\n                HTTP URL *\n              </Label>\n              <Input\n                id=\"http-url\"\n                placeholder=\"Example: http://localhost:8000/mcp\"\n                className=\"w-full bg-background text-foreground border-input\"\n                value={httpUrl}\n                onChange={(e) => setHttpUrl(e.target.value)}\n                required\n                disabled={isLoading}\n              />\n            </>\n          )}\n          {(serverType === 'sse' || serverType === 'streamable-http') && (\n            <>\n              <Label htmlFor=\"headers\" className=\"text-foreground/70 text-sm\">\n                Headers (JSON, optional)\n              </Label>\n              <Input\n                id=\"headers\"\n                placeholder='Example: {\"Authorization\": \"Bearer TOKEN\"}'\n                className=\"w-full bg-background text-foreground border-input font-mono\"\n                value={headersInput}\n                onChange={(e) => setHeadersInput(e.target.value)}\n                disabled={isLoading}\n              />\n            </>\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex justify-end items-center gap-2 mt-auto\">\n        <Button variant=\"outline\" onClick={onCancel} disabled={isLoading}>\n          <Translator path=\"common.actions.cancel\" />\n        </Button>\n        <Button\n          variant=\"default\"\n          onClick={addMcp}\n          disabled={!isFormValid() || isLoading}\n        >\n          <Translator path=\"common.actions.confirm\" />\n        </Button>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Mcp/AnimatedPlugIcon.tsx",
    "content": "import { Plug } from 'lucide-react';\nimport { useEffect, useRef } from 'react';\n\ninterface AnimatedPlugIconProps {\n  duration?: number;\n  strokeWidth?: number;\n  className?: string;\n}\n\nconst AnimatedPlugIcon: React.FC<AnimatedPlugIconProps> = ({\n  duration = 1500,\n  strokeWidth = 2,\n  className = ''\n}) => {\n  const iconRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    if (iconRef.current) {\n      // Get all SVG paths inside the icon\n      const paths = iconRef.current.querySelectorAll('path');\n\n      paths.forEach((path: SVGPathElement) => {\n        // Get the total length of the path\n        const length = path.getTotalLength();\n\n        // Set up the starting position\n        path.style.strokeDasharray = `${length}`;\n        path.style.strokeDashoffset = `${length}`;\n\n        // Create the animation\n        path.animate([{ strokeDashoffset: length }, { strokeDashoffset: 0 }], {\n          duration: duration,\n          easing: 'ease-in-out',\n          iterations: Infinity,\n          direction: 'alternate'\n        });\n      });\n    }\n  }, [duration]);\n\n  return (\n    <div ref={iconRef}>\n      <Plug className={className} strokeWidth={strokeWidth} />\n    </div>\n  );\n};\n\nexport default AnimatedPlugIcon;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Mcp/List.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Link, RefreshCw, SquareTerminal, Trash2, Wrench } from 'lucide-react';\nimport { useContext, useState } from 'react';\nimport { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  IMcp,\n  mcpState,\n  sessionIdState\n} from '@chainlit/react-client';\n\nimport CopyButton from '@/components/CopyButton';\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger\n} from '@/components/ui/alert-dialog';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Translator } from 'components/i18n';\n\ninterface McpListProps {\n  onAddNewClick: () => void;\n}\n\nexport const McpList = ({ onAddNewClick }: McpListProps) => {\n  const apiClient = useContext(ChainlitContext);\n  const sessionId = useRecoilValue(sessionIdState);\n  const [mcps, setMcps] = useRecoilState(mcpState);\n  const [isLoading, setIsLoading] = useState(false);\n\n  const deleteMcp = (mcp: IMcp) => {\n    if (mcp.status === 'connected') {\n      setIsLoading(true);\n\n      toast.promise(\n        apiClient\n          .disconnectMcp(sessionId, mcp.name)\n          .then(() => {})\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Removing MCP...',\n          success: () => 'MCP removed!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    }\n\n    setMcps((prev) => prev.filter((_mcp) => _mcp.name !== mcp.name));\n  };\n\n  if (!mcps || mcps.length === 0) {\n    return (\n      <div className=\"text-center py-8 text-muted-foreground\">\n        <p>No MCP servers connected</p>\n        <Button variant=\"outline\" className=\"mt-4\" onClick={onAddNewClick}>\n          Add your first MCP server\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      {mcps.map((mcp, index) => (\n        <McpItem\n          key={index}\n          mcp={mcp}\n          onDelete={deleteMcp}\n          isLoading={isLoading}\n        />\n      ))}\n    </>\n  );\n};\n\ninterface McpItemProps {\n  mcp: IMcp;\n  onDelete: (mcp: IMcp) => void;\n  isLoading: boolean;\n}\n\nconst McpItem = ({ mcp, onDelete, isLoading }: McpItemProps) => {\n  return (\n    <div className=\"border rounded-lg p-4 flex flex-col gap-3\">\n      <div className=\"flex justify-between items-center\">\n        <div className=\"flex items-center gap-2\">\n          <div\n            className={cn(\n              'h-2 w-2 rounded-full',\n              mcp.status === 'connected' && 'bg-green-500',\n              mcp.status === 'connecting' && 'bg-yellow-500',\n              mcp.status === 'failed' && 'bg-red-500'\n            )}\n          />\n          <h3 className=\"font-medium\">{mcp.name}</h3>\n          <Badge variant=\"outline\">{mcp.clientType}</Badge>\n        </div>\n        <div className=\"flex items-center\">\n          <ReconnectMcpButton mcp={mcp} />\n          <DeleteMcpButton mcp={mcp} onDelete={onDelete} disabled={isLoading} />\n        </div>\n      </div>\n\n      <div className=\"flex gap-2 flex-wrap\">\n        <div className=\"font-medium text-sm text-muted-foreground flex items-center\">\n          {mcp.clientType === 'stdio' ? (\n            <SquareTerminal className=\"h-4 w-4 mr-2\" />\n          ) : mcp.clientType === 'streamable-http' ? (\n            <Link className=\"h-4 w-4 mr-2 text-blue-500\" />\n          ) : (\n            <Link className=\"h-4 w-4 mr-2\" />\n          )}\n          {mcp.clientType === 'stdio'\n            ? 'Command'\n            : mcp.clientType === 'streamable-http'\n            ? 'HTTP URL'\n            : 'URL'}\n        </div>\n        <div className=\"flex items-center w-full bg-accent px-3 py-1 rounded gap-2\">\n          <pre className=\"text-sm font-mono flex-grow truncate\">\n            {mcp.command || mcp.url || 'N/A'}\n          </pre>\n          <CopyButton content={mcp.command || mcp.url} />\n        </div>\n      </div>\n\n      <div className=\"font-medium text-sm text-muted-foreground flex items-center\">\n        <Wrench className=\"h-4 w-4 mr-2\" />\n        Tools\n      </div>\n      <div className=\"flex flex-wrap gap-2\">\n        {mcp.tools &&\n          mcp.tools.map((tool, toolIndex) => (\n            <Badge key={toolIndex} variant=\"secondary\">\n              {tool.name}\n            </Badge>\n          ))}\n      </div>\n    </div>\n  );\n};\n\ninterface DeleteMcpButtonProps {\n  mcp: IMcp;\n  onDelete: (mcp: IMcp) => void;\n  disabled: boolean;\n}\n\nconst DeleteMcpButton = ({ mcp, onDelete, disabled }: DeleteMcpButtonProps) => {\n  return (\n    <AlertDialog>\n      <AlertDialogTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"text-destructive\"\n          disabled={disabled}\n        >\n          <Trash2 className=\"h-4 w-4\" />\n        </Button>\n      </AlertDialogTrigger>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n          <AlertDialogDescription>\n            This will disconnect the MCP server \"{mcp.name}\". This action cannot\n            be undone.\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>\n            <Translator path=\"common.actions.cancel\" />\n          </AlertDialogCancel>\n          <AlertDialogAction\n            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n            onClick={() => onDelete(mcp)}\n          >\n            <Translator path=\"common.actions.confirm\" />\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n\nconst ReconnectMcpButton = ({ mcp }: { mcp: IMcp }) => {\n  const apiClient = useContext(ChainlitContext);\n  const setMcps = useSetRecoilState(mcpState);\n  const sessionId = useRecoilValue(sessionIdState);\n  const [isLoading, setIsLoading] = useState(false);\n\n  const reconnectMcp = () => {\n    setIsLoading(true);\n\n    setMcps((prev) =>\n      prev.map((existingMcp) => {\n        if (existingMcp.name === mcp.name) {\n          return {\n            ...existingMcp,\n            status: 'connecting'\n          };\n        }\n        return existingMcp;\n      })\n    );\n\n    const updateMcpStatus = (success: boolean, updatedMcp?: any) => {\n      setMcps((prev) =>\n        prev.map((existingMcp) => {\n          if (existingMcp.name === mcp.name) {\n            return {\n              ...existingMcp,\n              status: success ? 'connected' : 'failed',\n              tools: updatedMcp ? updatedMcp.tools : existingMcp.tools\n            };\n          }\n          return existingMcp;\n        })\n      );\n    };\n\n    if (mcp.clientType === 'stdio') {\n      toast.promise(\n        apiClient\n          .connectStdioMCP(sessionId, mcp.name, mcp.command!)\n          .then(async (resp: any) => {\n            const { success, mcp: updatedMcp } = resp;\n            updateMcpStatus(success, updatedMcp);\n          })\n          .catch(() => {\n            updateMcpStatus(false);\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Reconnecting MCP...',\n          success: () => 'MCP reconnected!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    } else if (mcp.clientType === 'streamable-http') {\n      toast.promise(\n        (apiClient as any)\n          .connectStreamableHttpMCP(\n            sessionId,\n            mcp.name,\n            mcp.url!,\n            (mcp as any).headers\n          )\n          .then(async (resp: any) => {\n            const { success, mcp: updatedMcp } = resp;\n            updateMcpStatus(success, updatedMcp);\n          })\n          .catch(() => {\n            updateMcpStatus(false);\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Reconnecting MCP...',\n          success: () => 'MCP reconnected!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    } else {\n      toast.promise(\n        (apiClient as any)\n          .connectSseMCP(sessionId, mcp.name, mcp.url!, (mcp as any).headers)\n          .then(async (resp: any) => {\n            const { success, mcp: updatedMcp } = resp;\n            updateMcpStatus(success, updatedMcp);\n          })\n          .catch(() => {\n            updateMcpStatus(false);\n          })\n          .finally(() => setIsLoading(false)),\n        {\n          loading: 'Reconnecting MCP...',\n          success: () => 'MCP reconnected!',\n          error: (err) => <span>{err.message}</span>\n        }\n      );\n    }\n  };\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      disabled={isLoading}\n      onClick={reconnectMcp}\n    >\n      <RefreshCw className=\"h-4 w-4\" />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/Mcp/index.tsx",
    "content": "import { Plug } from 'lucide-react';\nimport { useState } from 'react';\nimport { useRecoilState } from 'recoil';\n\nimport { mcpState, useConfig } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger\n} from '@/components/ui/dialog';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { McpAddForm } from './AddForm';\nimport AnimatedPlugIcon from './AnimatedPlugIcon';\nimport { McpList } from './List';\n\ninterface Props {\n  disabled?: boolean;\n}\n\nconst McpButton = ({ disabled }: Props) => {\n  const { config } = useConfig();\n  const [mcps] = useRecoilState(mcpState);\n\n  const [open, setOpen] = useState(false);\n  const [activeTab, setActiveTab] = useState('add');\n\n  const allowSse = !!config?.features.mcp?.sse?.enabled;\n  const allowStdio = !!config?.features.mcp?.stdio?.enabled;\n  const allowHttp = !!config?.features.mcp?.streamable_http?.enabled;\n  const allowMcp = !!config?.features.mcp?.enabled;\n\n  if (!allowMcp || (!allowSse && !allowStdio && !allowHttp)) return null;\n\n  const connectedMcps = mcps.filter((mcp) => mcp.status === 'connected');\n\n  const mcpLoading = mcps.find((mcp) => mcp.status === 'connecting');\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger>\n        <TooltipProvider>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                disabled={disabled}\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"hover:bg-muted relative\"\n              >\n                {mcpLoading ? (\n                  <AnimatedPlugIcon className=\"!size-5\" />\n                ) : (\n                  <Plug className=\"!size-5\" />\n                )}\n                {connectedMcps.length > 0 && (\n                  <span className=\"absolute top-0.5 right-0.5 bg-primary text-primary-foreground text-[8px] font-medium rounded-full w-3 h-3 flex items-center justify-center\">\n                    {connectedMcps.length}\n                  </span>\n                )}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>MCP Servers</p>\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      </DialogTrigger>\n      <DialogContent\n        id=\"mcp-servers\"\n        className=\"min-w-[50vw] max-h-[85vh] flex flex-col gap-6 bg-background overflow-y-auto\"\n      >\n        <DialogHeader>\n          <DialogTitle>MCP Servers</DialogTitle>\n        </DialogHeader>\n        <Tabs value={activeTab} onValueChange={setActiveTab} className=\"w-full\">\n          <TabsList className=\"grid grid-cols-2 mb-4\">\n            <TabsTrigger value=\"add\">Connect an MCP</TabsTrigger>\n            <TabsTrigger value=\"list\">My MCPs</TabsTrigger>\n          </TabsList>\n\n          <TabsContent\n            value=\"add\"\n            className=\"flex flex-col flex-grow gap-6 p-1\"\n          >\n            <McpAddForm\n              allowSse={allowSse}\n              allowStdio={allowStdio}\n              allowHttp={allowHttp}\n              onSuccess={() => setActiveTab('list')}\n              onCancel={() => setOpen(false)}\n            />\n          </TabsContent>\n\n          <TabsContent value=\"list\" className=\"flex flex-col gap-4\">\n            <McpList onAddNewClick={() => setActiveTab('add')} />\n          </TabsContent>\n        </Tabs>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default McpButton;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/ModePicker.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport {\n    Popover,\n    PopoverContent,\n    PopoverTrigger\n} from '@radix-ui/react-popover';\nimport { ChevronDown, ChevronUp } from 'lucide-react';\nimport { useContext, useRef, useState } from 'react';\n\nimport { ChainlitContext, IMode, IModeOption } from '@chainlit/react-client';\n\nimport Icon from '@/components/Icon';\nimport { Button } from '@/components/ui/button';\nimport {\n    Command,\n    CommandGroup,\n    CommandItemAnimated,\n    CommandListScrollable\n} from '@/components/ui/command';\n\ninterface Props {\n    mode: IMode;\n    disabled?: boolean;\n    selectedOptionId?: string;\n    onOptionSelect: (modeId: string, optionId: string) => void;\n}\n\n/**\n * ModePicker displays a single mode category and allows selection from its options.\n * Multiple ModePicker instances can be rendered for different mode categories.\n */\nexport const ModePicker = ({\n    mode,\n    disabled = false,\n    selectedOptionId,\n    onOptionSelect\n}: Props) => {\n    const apiClient = useContext(ChainlitContext);\n    const [open, setOpen] = useState(false);\n    const [selectedIndex, setSelectedIndex] = useState(0);\n    const popoverRef = useRef<HTMLDivElement>(null);\n\n    const options = mode.options;\n    const selectedOption = options.find(opt => opt.id === selectedOptionId) || options[0];\n\n    // Handle option selection\n    const handleOptionSelect = (option: IModeOption) => {\n        onOptionSelect(mode.id, option.id);\n        setOpen(false);\n    };\n\n    // Keyboard navigation\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n        if (!open) {\n            if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {\n                e.preventDefault();\n                setOpen(true);\n            }\n            return;\n        }\n\n        switch (e.key) {\n            case 'ArrowDown':\n                e.preventDefault();\n                setSelectedIndex((prev) => (prev + 1) % options.length);\n                break;\n            case 'ArrowUp':\n                e.preventDefault();\n                setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);\n                break;\n            case 'Enter':\n                e.preventDefault();\n                if (options[selectedIndex]) {\n                    handleOptionSelect(options[selectedIndex]);\n                }\n                break;\n            case 'Escape':\n                e.preventDefault();\n                setOpen(false);\n                break;\n        }\n    };\n\n    const handleMouseMove = (index: number) => {\n        setSelectedIndex(index);\n    };\n\n    const handleMouseLeave = () => {\n        // Keep current selection on mouse leave\n    };\n\n    // Helper to render icon - supports Lucide names, local paths, and URLs\n    const renderIcon = (icon: string | undefined, className: string) => {\n        if (!icon) return null;\n\n        // Local public file path\n        if (icon.startsWith('/public')) {\n            return (\n                <img\n                    className={cn('rounded-md', className)}\n                    src={apiClient.buildEndpoint(icon)}\n                    alt=\"Mode option icon\"\n                />\n            );\n        }\n\n        // Remote URL\n        if (icon.startsWith('http://') || icon.startsWith('https://')) {\n            return (\n                <img\n                    className={cn('rounded-md', className)}\n                    src={icon}\n                    alt=\"Mode option icon\"\n                />\n            );\n        }\n\n        // Lucide icon name\n        return <Icon name={icon} className={className} />;\n    };\n\n    if (!options.length) return null;\n\n    const Chevron = open ? ChevronUp : ChevronDown;\n\n    return (\n        <div className=\"mode-picker-wrapper inline-flex items-center\" ref={popoverRef}>\n            <Popover open={open} onOpenChange={setOpen}>\n                <PopoverTrigger asChild>\n                    <Button\n                        id={`mode-picker-trigger-${mode.id}`}\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        disabled={disabled}\n                        className={cn(\n                            'inline-flex items-center gap-1.5 h-7 px-2 rounded-md',\n                            'text-xs font-medium',\n                            'hover:bg-muted transition-colors',\n                            'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',\n                            open && 'bg-muted'\n                        )}\n                        onKeyDown={handleKeyDown}\n                    >\n                        {renderIcon(selectedOption?.icon, '!size-4')}\n                        <span className=\"max-w-[120px] truncate\">\n                            {selectedOption?.name || mode.name}\n                        </span>\n                        <Chevron className=\"!size-3.5 text-muted-foreground\" />\n                    </Button>\n                </PopoverTrigger>\n\n                <PopoverContent\n                    id={`mode-picker-popover-${mode.id}`}\n                    align=\"start\"\n                    side=\"top\"\n                    sideOffset={4}\n                    className={cn(\n                        'p-1 rounded-md border shadow-lg bg-popover',\n                        'animate-in fade-in-0 zoom-in-95 duration-150',\n                        'w-[280px]'\n                    )}\n                    onKeyDown={handleKeyDown}\n                    onMouseLeave={handleMouseLeave}\n                >\n                    <Command className=\"overflow-hidden bg-transparent\">\n                        <CommandListScrollable maxItems={6} className=\"custom-scrollbar\">\n                            <CommandGroup className=\"p-0\">\n                                {options.map((option, index) => (\n                                    <CommandItemAnimated\n                                        key={option.id}\n                                        index={index}\n                                        isSelected={index === selectedIndex}\n                                        onMouseMove={() => handleMouseMove(index)}\n                                        onSelect={() => handleOptionSelect(option)}\n                                        className={cn(\n                                            'flex items-start gap-2 px-2 py-2 cursor-pointer',\n                                            selectedOptionId === option.id && 'bg-accent'\n                                        )}\n                                    >\n                                        {renderIcon(\n                                            option.icon,\n                                            cn(\n                                                '!size-5 mt-0.5 text-muted-foreground flex-shrink-0',\n                                                index === selectedIndex && 'text-foreground'\n                                            )\n                                        )}\n                                        <div className=\"flex-1 min-w-0\">\n                                            <div className=\"font-medium text-sm leading-tight\">\n                                                {option.name}\n                                            </div>\n                                            {option.description && (\n                                                <div className=\"text-xs text-muted-foreground mt-0.5 leading-tight\">\n                                                    {option.description}\n                                                </div>\n                                            )}\n                                        </div>\n                                    </CommandItemAnimated>\n                                ))}\n                            </CommandGroup>\n                        </CommandListScrollable>\n                    </Command>\n                </PopoverContent>\n            </Popover>\n        </div>\n    );\n};\n\nexport default ModePicker;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/SubmitButton.tsx",
    "content": "import {\n  useChatData,\n  useChatInteract,\n  useChatMessages\n} from '@chainlit/react-client';\n\nimport { Send } from '@/components/icons/Send';\nimport { Stop } from '@/components/icons/Stop';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\ninterface SubmitButtonProps {\n  disabled?: boolean;\n  onSubmit: () => void;\n}\n\nexport default function SubmitButton({\n  disabled,\n  onSubmit\n}: SubmitButtonProps) {\n  const { loading } = useChatData();\n  const { firstInteraction } = useChatMessages();\n  const { stopTask } = useChatInteract();\n\n  return (\n    <TooltipProvider>\n      {loading && firstInteraction ? (\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              id=\"stop-button\"\n              onClick={stopTask}\n              size=\"icon\"\n              className=\"rounded-full h-8 w-8\"\n            >\n              <Stop className=\"!size-6\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              <Translator path=\"chat.input.actions.stop\" />\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      ) : (\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              id=\"chat-submit\"\n              disabled={disabled}\n              onClick={onSubmit}\n              size=\"icon\"\n              className=\"rounded-full h-8 w-8\"\n            >\n              <Send className=\"!size-6\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              <Translator path=\"chat.input.actions.send\" />\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      )}\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/UploadButton.tsx",
    "content": "import { FileSpec, useConfig } from '@chainlit/react-client';\n\nimport { Translator } from '@/components/i18n';\nimport { PaperClip } from '@/components/icons/PaperClip';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { useUpload } from '@/hooks/useUpload';\n\ninterface UploadButtonProps {\n  disabled?: boolean;\n  fileSpec: FileSpec;\n  onFileUpload: (files: File[]) => void;\n  onFileUploadError: (error: string) => void;\n}\n\nexport const UploadButton = ({\n  disabled = false,\n  fileSpec,\n  onFileUpload,\n  onFileUploadError\n}: UploadButtonProps) => {\n  const { config } = useConfig();\n  const upload = useUpload({\n    spec: fileSpec,\n    onResolved: (payloads: File[]) => onFileUpload(payloads),\n    onError: onFileUploadError,\n    options: { noDrag: true }\n  });\n\n  if (!upload) return null;\n  const { getRootProps, getInputProps } = upload;\n\n  if (!config?.features.spontaneous_file_upload?.enabled) return null;\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span className=\"inline-block\">\n            <input\n              id=\"upload-button-input\"\n              className=\"hidden\"\n              {...getInputProps()}\n            />\n            <Button\n              id={disabled ? 'upload-button-loading' : 'upload-button'}\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"hover:bg-muted\"\n              disabled={disabled}\n              {...getRootProps()}\n            >\n              <PaperClip className=\"!size-6\" />\n            </Button>\n          </span>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            <Translator path=\"chat.input.actions.attachFiles\" />\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nexport default UploadButton;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/VoiceButton.tsx",
    "content": "import { X } from 'lucide-react';\nimport { useHotkeys } from 'react-hotkeys-hook';\n\nimport { useAudio, useConfig } from '@chainlit/react-client';\n\nimport AudioPresence from '@/components/AudioPresence';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\nimport { Loader } from '../../Loader';\nimport { VoiceLines } from '../../icons/VoiceLines';\nimport { Button } from '../../ui/button';\n\ninterface Props {\n  disabled?: boolean;\n}\n\nconst VoiceButton = ({ disabled }: Props) => {\n  const { config } = useConfig();\n  const { startConversation, endConversation, audioConnection } = useAudio();\n  const isEnabled = !!config?.features.audio.enabled;\n\n  useHotkeys(\n    'p',\n    () => {\n      if (!isEnabled) return;\n\n      // Double-check at execution time that we're not in a form field\n      const getDeepActiveElement = (): Element | null => {\n        let activeElement = document.activeElement;\n        while (\n          activeElement &&\n          activeElement.shadowRoot &&\n          activeElement.shadowRoot.activeElement\n        ) {\n          activeElement = activeElement.shadowRoot.activeElement;\n        }\n        return activeElement;\n      };\n\n      const activeElement = getDeepActiveElement();\n      if (activeElement) {\n        const tagName = activeElement.tagName.toLowerCase();\n        const isFormField = ['input', 'textarea', 'select'].includes(tagName);\n        const isContentEditable =\n          activeElement.getAttribute('contenteditable') === 'true';\n\n        if (isFormField || isContentEditable) {\n          return; // Don't execute the hotkey\n        }\n      }\n\n      if (audioConnection === 'on') return endConversation();\n      return startConversation();\n    },\n    {\n      enableOnFormTags: false,\n      preventDefault: false // Don't prevent default - let letters be typed\n    },\n    [isEnabled, audioConnection, startConversation, endConversation]\n  );\n\n  if (!isEnabled) return null;\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {audioConnection === 'on' ? (\n        <AudioPresence\n          type=\"client\"\n          height={18}\n          width={36}\n          barCount={4}\n          barSpacing={2}\n        />\n      ) : null}\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              disabled={disabled}\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"hover:bg-muted\"\n              onClick={\n                audioConnection === 'on'\n                  ? endConversation\n                  : audioConnection === 'off'\n                  ? startConversation\n                  : undefined\n              }\n            >\n              {audioConnection === 'on' ? <X className=\"!size-5\" /> : null}\n              {audioConnection === 'off' ? (\n                <VoiceLines className=\"!size-6\" />\n              ) : null}\n              {audioConnection === 'connecting' ? (\n                <Loader className=\"!size-5\" />\n              ) : null}\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              <Translator\n                path={\n                  audioConnection === 'on'\n                    ? 'chat.speech.stop'\n                    : audioConnection === 'off'\n                    ? 'chat.speech.start'\n                    : 'chat.speech.connecting'\n                }\n                suffix=\" (P)\"\n              />\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </div>\n  );\n};\nexport default VoiceButton;\n"
  },
  {
    "path": "frontend/src/components/chat/MessageComposer/index.tsx",
    "content": "import {\n  MutableRefObject,\n  useCallback,\n  useEffect,\n  useRef,\n  useState\n} from 'react';\nimport { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport {\n  FileSpec,\n  IStep,\n  commandsState,\n  useAuth,\n  useChatData,\n  useChatInteract,\n  useConfig\n} from '@chainlit/react-client';\nimport type { IMode, IModeOption } from '@chainlit/react-client';\nimport { modesState } from '@chainlit/react-client';\n\nimport { Settings } from '@/components/icons/Settings';\nimport { Button } from '@/components/ui/button';\nimport { useTranslation } from 'components/i18n/Translator';\n\nimport { useQuery } from '@/hooks/query';\nimport { useIsMobile } from '@/hooks/use-mobile';\n\nimport { chatSettingsOpenState } from '@/state/project';\nimport {\n  IAttachment,\n  attachmentsState,\n  persistentCommandState\n} from 'state/chat';\n\nimport { Attachments } from './Attachments';\nimport CommandButtons from './CommandButtons';\nimport CommandButton from './CommandPopoverButton';\nimport FavoriteButton from './FavoriteButton';\nimport Input, { InputMethods } from './Input';\nimport McpButton from './Mcp';\nimport ModePicker from './ModePicker';\nimport SubmitButton from './SubmitButton';\nimport UploadButton from './UploadButton';\nimport VoiceButton from './VoiceButton';\n\ninterface Props {\n  fileSpec: FileSpec;\n  onFileUpload: (payload: File[]) => void;\n  onFileUploadError: (error: string) => void;\n  autoScrollRef: MutableRefObject<boolean>;\n}\n\nexport default function MessageComposer({\n  fileSpec,\n  onFileUpload,\n  onFileUploadError,\n  autoScrollRef\n}: Props) {\n  const inputRef = useRef<InputMethods>(null);\n  const [value, setValue] = useState('');\n  const [selectedCommand, setSelectedCommand] = useRecoilState(\n    persistentCommandState\n  );\n  const commands = useRecoilValue(commandsState);\n  const setChatSettingsOpen = useSetRecoilState(chatSettingsOpenState);\n\n  // Pre-select the command marked as selected by the backend\n  useEffect(() => {\n    const defaultSelected = commands.find((c) => c.selected);\n    if (defaultSelected && !selectedCommand) {\n      setSelectedCommand(defaultSelected);\n    }\n  }, [commands]);\n  const [attachments, setAttachments] = useRecoilState(attachmentsState);\n  const { t } = useTranslation();\n\n  const { user } = useAuth();\n  const { sendMessage, replyMessage } = useChatInteract();\n  const { askUser, chatSettingsInputs, disabled: _disabled } = useChatData();\n\n  const disabled = _disabled || !!attachments.find((a) => !a.uploaded);\n\n  const { config } = useConfig();\n  const showSettingsInComposer =\n    config?.ui?.chat_settings_location !== 'sidebar' &&\n    chatSettingsInputs.length > 0;\n\n  const isMobile = useIsMobile();\n\n  // Get/set available modes from state - selections are tracked via the 'default' flag on options\n  const [modes, setModes] = useRecoilState(modesState);\n\n  const handleModeSelect = useCallback(\n    (modeId: string, optionId: string) => {\n      setModes((prevModes) =>\n        prevModes.map((mode) => {\n          if (mode.id !== modeId) return mode;\n          return {\n            ...mode,\n            options: mode.options.map((opt: IModeOption) => ({\n              ...opt,\n              default: opt.id === optionId\n            }))\n          };\n        })\n      );\n    },\n    [setModes]\n  );\n\n  // Helper to get selected option for a mode (the one with default=true, or first option)\n  const getSelectedOptionId = useCallback((mode: IMode): string | undefined => {\n    const defaultOpt = mode.options.find((opt) => opt.default);\n    return defaultOpt?.id || mode.options[0]?.id;\n  }, []);\n\n  let promptValue = '';\n  try {\n    const query = useQuery();\n    promptValue = query.get('prompt') || '';\n  } catch {\n    console.warn('Could not parse query parameters');\n  }\n\n  const [promptUsed, setPromptUsed] = useState(false);\n\n  const onFavoriteSelect = useCallback((content: string) => {\n    setValue(content);\n    if (inputRef.current) {\n      inputRef.current.setValueExtern(content);\n    }\n  }, []);\n\n  const onPaste = useCallback(\n    (event: ClipboardEvent) => {\n      if (event.clipboardData && event.clipboardData.items) {\n        const items = Array.from(event.clipboardData.items);\n\n        // If no text data, check for files (e.g., images)\n        items.forEach((item) => {\n          if (item.kind === 'file') {\n            const file = item.getAsFile();\n            if (file) {\n              onFileUpload([file]);\n            }\n          }\n        });\n      }\n    },\n    [onFileUpload]\n  );\n\n  const onSubmit = useCallback(\n    async (\n      msg: string,\n      attachments?: IAttachment[],\n      selectedCommand?: string\n    ) => {\n      // Build modes dict: only include modes that have selections\n      const modesDict: Record<string, string> = {};\n      modes.forEach((mode) => {\n        const selectedId = getSelectedOptionId(mode);\n        if (selectedId) {\n          modesDict[mode.id] = selectedId;\n        }\n      });\n\n      const message: IStep = {\n        threadId: '',\n        command: selectedCommand,\n        modes: Object.keys(modesDict).length > 0 ? modesDict : undefined,\n        id: uuidv4(),\n        name: user?.identifier || 'User',\n        type: 'user_message',\n        output: msg,\n        createdAt: new Date().toISOString(),\n        metadata: { location: window.location.href }\n      };\n\n      const fileReferences = attachments\n        ?.filter((a) => !!a.serverId)\n        .map((a) => ({ id: a.serverId! }));\n\n      if (autoScrollRef) {\n        autoScrollRef.current = true;\n      }\n      sendMessage(message, fileReferences);\n    },\n    [user, sendMessage, autoScrollRef, modes, getSelectedOptionId]\n  );\n\n  const onReply = useCallback(\n    async (msg: string) => {\n      const message: IStep = {\n        threadId: '',\n        id: uuidv4(),\n        name: user?.identifier || 'User',\n        type: 'user_message',\n        output: msg,\n        createdAt: new Date().toISOString(),\n        metadata: { location: window.location.href }\n      };\n\n      replyMessage(message);\n      if (autoScrollRef) {\n        autoScrollRef.current = true;\n      }\n    },\n    [user, replyMessage, autoScrollRef]\n  );\n\n  const submit = useCallback(() => {\n    if (\n      disabled ||\n      (value.trim() === '' && attachments.length === 0 && !selectedCommand)\n    ) {\n      return;\n    }\n\n    if (askUser) {\n      onReply(value);\n    } else {\n      onSubmit(value, attachments, selectedCommand?.id);\n    }\n\n    setAttachments([]);\n    setValue(''); // Clear the value state\n    inputRef.current?.reset();\n  }, [\n    value,\n    disabled,\n    askUser,\n    attachments,\n    selectedCommand,\n    setAttachments,\n    onSubmit,\n    onReply\n  ]);\n\n  useEffect(() => {\n    if (inputRef.current && promptValue && !promptUsed) {\n      const prompt = promptValue;\n      if (prompt) {\n        if (prompt.length > 1000) {\n          inputRef.current?.setValueExtern(prompt.slice(0, 1000));\n        } else {\n          inputRef.current?.setValueExtern(prompt);\n        }\n        setPromptUsed(true);\n      }\n    }\n  }, [promptValue, promptUsed]);\n\n  return (\n    <div\n      id=\"message-composer\"\n      className=\"bg-accent dark:bg-card rounded-3xl p-3 px-4 w-full min-h-24 flex flex-col\"\n    >\n      {attachments.length > 0 ? (\n        <div className=\"mb-1\">\n          <Attachments />\n        </div>\n      ) : null}\n      <Input\n        ref={inputRef}\n        id=\"chat-input\"\n        autoFocus={!isMobile}\n        selectedCommand={selectedCommand}\n        setSelectedCommand={setSelectedCommand}\n        onChange={setValue}\n        onPaste={onPaste}\n        onEnter={submit}\n        placeholder={t('chat.input.placeholder')}\n      />\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center -ml-1.5\">\n          <VoiceButton disabled={disabled} />\n          <UploadButton\n            disabled={disabled}\n            fileSpec={fileSpec}\n            onFileUploadError={onFileUploadError}\n            onFileUpload={onFileUpload}\n          />\n          {showSettingsInComposer && (\n            <Button\n              id=\"chat-settings-open-modal\"\n              disabled={disabled}\n              onClick={() => setChatSettingsOpen(true)}\n              className=\"hover:bg-muted rounded-full\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <Settings className=\"!size-6\" />\n            </Button>\n          )}\n          <McpButton disabled={disabled} />\n          {modes.map((mode) => (\n            <ModePicker\n              key={mode.id}\n              mode={mode}\n              disabled={disabled}\n              selectedOptionId={getSelectedOptionId(mode)}\n              onOptionSelect={handleModeSelect}\n            />\n          ))}\n          <CommandButton\n            disabled={disabled}\n            selectedCommandId={selectedCommand?.id}\n            onCommandSelect={setSelectedCommand}\n          />\n          <CommandButtons\n            disabled={disabled}\n            selectedCommandId={selectedCommand?.id}\n            onCommandSelect={setSelectedCommand}\n          />\n\n          <FavoriteButton disabled={disabled} onSelect={onFavoriteSelect} />\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <SubmitButton\n            onSubmit={submit}\n            disabled={\n              disabled ||\n              (!value.trim() && !selectedCommand && attachments.length === 0)\n            }\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/AskActionButtons.tsx",
    "content": "import { MessageContext } from 'contexts/MessageContext';\nimport { useContext, useMemo } from 'react';\n\nimport { type IAction } from '@chainlit/react-client';\n\nimport Icon from '@/components/Icon';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nconst AskActionButton = ({ action }: { action: IAction }) => {\n  const { loading, askUser } = useContext(MessageContext);\n\n  const content = useMemo(() => {\n    return action.icon\n      ? action.label\n      : action.label\n      ? action.label\n      : action.name;\n  }, [action]);\n\n  const icon = useMemo(() => {\n    if (action.icon) return <Icon name={action.icon as any} />;\n    return null;\n  }, [action]);\n\n  const button = (\n    <Button\n      className=\"break-words h-auto min-h-10 whitespace-normal\"\n      id={action.id}\n      onClick={() => {\n        askUser?.callback(action);\n      }}\n      variant=\"outline\"\n      disabled={loading}\n    >\n      {icon}\n      {content}\n    </Button>\n  );\n\n  if (action.tooltip) {\n    return (\n      <TooltipProvider delayDuration={100}>\n        <Tooltip>\n          <TooltipTrigger asChild>{button}</TooltipTrigger>\n          <TooltipContent>\n            <p>{action.tooltip}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  } else {\n    return button;\n  }\n};\n\nconst AskActionButtons = ({\n  messageId,\n  actions\n}: {\n  messageId: string;\n  actions: IAction[];\n}) => {\n  const { askUser } = useContext(MessageContext);\n\n  const belongsToMessage = askUser?.spec.step_id === messageId;\n  const isAskingAction = askUser?.spec.type === 'action';\n  const filteredActions = actions.filter((a) => {\n    return a.forId === messageId && askUser?.spec.keys?.includes(a.id);\n  });\n\n  if (!belongsToMessage || !isAskingAction || !actions.length) return null;\n\n  return (\n    <div className=\"flex items-center gap-1 flex-wrap\">\n      {filteredActions.map((a) => (\n        <AskActionButton key={a.id} action={a} />\n      ))}\n    </div>\n  );\n};\n\nexport { AskActionButtons };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/AskFileButton.tsx",
    "content": "import { MessageContext } from 'contexts/MessageContext';\nimport { Upload } from 'lucide-react';\nimport { useContext, useState } from 'react';\n\nimport { IAsk, IFileRef } from '@chainlit/react-client';\n\nimport { Translator } from '@/components/i18n';\nimport { useTranslation } from '@/components/i18n/Translator';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\n\nimport { useUpload } from 'hooks/useUpload';\n\ninterface UploadState {\n  progress: number;\n  uploaded: boolean;\n  cancel: () => void;\n  fileRef?: IFileRef;\n}\n\ninterface _AskFileButtonProps {\n  askUser: IAsk;\n  parentId?: string;\n  uploadFile: (\n    file: File,\n    onProgress: (progress: number) => void,\n    parentId?: string\n  ) => {\n    xhr: XMLHttpRequest;\n    promise: Promise<IFileRef>;\n  };\n  onError: (error: string) => void;\n}\n\nconst CircularProgress = ({ value }: { value: number }) => {\n  const size = 24;\n  const strokeWidth = 2;\n  const radius = (size - strokeWidth) / 2;\n  const circumference = 2 * Math.PI * radius;\n  const strokeDashoffset = circumference - (value / 100) * circumference;\n\n  return (\n    <div className=\"relative inline-flex items-center justify-center\">\n      <svg\n        className=\"absolute\"\n        width={size}\n        height={size}\n        viewBox={`0 0 ${size} ${size}`}\n      >\n        <circle\n          className=\"text-muted-foreground/20\"\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={strokeWidth}\n          stroke=\"currentColor\"\n        />\n        <circle\n          className=\"text-primary transition-all duration-300 ease-in-out\"\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={strokeWidth}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeDasharray={circumference}\n          strokeDashoffset={strokeDashoffset}\n          transform={`rotate(-90 ${size / 2} ${size / 2})`}\n        />\n      </svg>\n    </div>\n  );\n};\n\nconst _AskFileButton = ({\n  askUser,\n  uploadFile,\n  onError\n}: _AskFileButtonProps) => {\n  const { t } = useTranslation();\n\n  const [uploads, setUploads] = useState<UploadState[]>([]);\n\n  const uploading = uploads.some((upload) => !upload.uploaded);\n  const progress = uploads.reduce(\n    (acc, upload) => acc + upload.progress / uploads.length,\n    0\n  );\n\n  const onResolved = (files: File[]) => {\n    if (uploading) return;\n\n    const promises: Promise<IFileRef>[] = [];\n\n    const newUploads = files.map((file, index) => {\n      const { xhr, promise } = uploadFile(\n        file,\n        (progress) => {\n          setUploads((prev) =>\n            prev.map((upload, i) => {\n              if (i === index) {\n                return { ...upload, progress };\n              }\n              return upload;\n            })\n          );\n        },\n        askUser?.parentId\n      );\n      promises.push(promise);\n      return { progress: 0, uploaded: false, cancel: () => xhr.abort() };\n    });\n\n    Promise.all(promises)\n      .then((fileRefs) => askUser.callback(fileRefs))\n      .catch((error) => {\n        onError(\n          `${t('chat.fileUpload.errors.failed')}: ${\n            typeof error === 'object' && error !== null\n              ? error.message ?? error\n              : error\n          }`\n        );\n        setUploads((prev) => {\n          prev.forEach((u) => u.cancel());\n          return [];\n        });\n      });\n\n    setUploads(newUploads);\n  };\n\n  const upload = useUpload({\n    spec: askUser.spec,\n    onResolved: onResolved,\n    onError: (error: string) => onError(error)\n  });\n\n  if (!upload) return null;\n  const { getRootProps, getInputProps } = upload;\n\n  return (\n    <Card className=\"w-full mt-2\">\n      <div\n        {...getRootProps({ className: 'dropzone' })}\n        className=\"flex items-center p-4\"\n      >\n        <input id=\"ask-button-input\" {...getInputProps()} />\n        <div className=\"flex flex-col\">\n          <p className=\"text-sm font-medium\">\n            <Translator path=\"chat.fileUpload.dragDrop\" />\n          </p>\n          <p className=\"text-sm text-muted-foreground\">\n            <Translator path=\"chat.fileUpload.sizeLimit\" />{' '}\n            {askUser.spec.max_size_mb}mb\n          </p>\n        </div>\n        <Button\n          id={uploading ? 'ask-upload-button-loading' : 'ask-upload-button'}\n          disabled={uploading}\n          className=\"ml-auto\"\n          variant={uploading ? 'ghost' : 'default'}\n        >\n          {uploading ? (\n            <CircularProgress value={progress} />\n          ) : (\n            <>\n              <Upload className=\"w-4 h-4 mr-2\" />\n              <Translator path=\"chat.fileUpload.browse\" />\n            </>\n          )}\n        </Button>\n      </div>\n    </Card>\n  );\n};\n\ninterface AskFileButtonProps {\n  messageId: string;\n  onError: (error: string) => void;\n}\n\nconst AskFileButton = ({ messageId, onError }: AskFileButtonProps) => {\n  const messageContext = useContext(MessageContext);\n  const belongsToMessage = messageContext.askUser?.spec.step_id === messageId;\n  const isAskFile = messageContext.askUser?.spec.type === 'file';\n\n  if (!belongsToMessage || !isAskFile || !messageContext?.uploadFile)\n    return null;\n\n  return (\n    <_AskFileButton\n      onError={onError}\n      uploadFile={messageContext.uploadFile}\n      askUser={messageContext.askUser!}\n    />\n  );\n};\n\nexport { AskFileButton };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Avatar.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { AlertCircle } from 'lucide-react';\nimport { useContext, useMemo } from 'react';\n\nimport {\n  ChainlitContext,\n  useChatSession,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface Props {\n  author?: string;\n  hide?: boolean;\n  isError?: boolean;\n}\n\nconst MessageAvatar = ({ author, hide, isError }: Props) => {\n  const apiClient = useContext(ChainlitContext);\n  const { chatProfile } = useChatSession();\n  const { config } = useConfig();\n\n  const selectedChatProfile = useMemo(() => {\n    return config?.chatProfiles.find((profile) => profile.name === chatProfile);\n  }, [config, chatProfile]);\n\n  const avatarUrl = useMemo(() => {\n    if (config?.ui?.default_avatar_file_url)\n      return config?.ui?.default_avatar_file_url;\n    const isAssistant = !author || author === config?.ui.name;\n    if (isAssistant && selectedChatProfile?.icon) {\n      return selectedChatProfile.icon;\n    }\n    return apiClient?.buildEndpoint(`/avatars/${author || 'default'}`);\n  }, [apiClient, selectedChatProfile, config, author]);\n\n  const avatarSize = config?.ui?.avatar_size;\n  const sizeStyle = avatarSize\n    ? { width: `${avatarSize}px`, height: `${avatarSize}px` }\n    : undefined;\n\n  if (isError) {\n    return (\n      <span className={cn('inline-block', hide && 'invisible')}>\n        <AlertCircle className=\"h-5 w-5 fill-destructive mt-[5px] text-destructive-foreground\" />\n      </span>\n    );\n  }\n\n  return (\n    <span className={cn('inline-block', hide && 'invisible')}>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Avatar\n              className={avatarSize ? 'mt-[3px]' : 'h-5 w-5 mt-[3px]'}\n              style={sizeStyle}\n            >\n              <AvatarImage\n                src={avatarUrl}\n                alt={`Avatar for ${author || 'default'}`}\n                className=\"bg-transparent\"\n              />\n              <AvatarFallback className=\"bg-transparent\">\n                <Skeleton className=\"h-full w-full rounded-full\" />\n              </AvatarFallback>\n            </Avatar>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{author}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </span>\n  );\n};\n\nexport { MessageAvatar };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Buttons/Actions/ActionButton.tsx",
    "content": "import { MessageContext } from 'contexts/MessageContext';\nimport { useCallback, useContext, useMemo, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  type IAction,\n  sessionIdState\n} from '@chainlit/react-client';\n\nimport Icon from '@/components/Icon';\nimport { Loader } from '@/components/Loader';\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface ActionProps {\n  action: IAction;\n}\n\nconst ActionButton = ({ action }: ActionProps) => {\n  const { loading, askUser } = useContext(MessageContext);\n  const apiClient = useContext(ChainlitContext);\n  const sessionId = useRecoilValue(sessionIdState);\n  const [isRunning, setIsRunning] = useState(false);\n\n  const content = useMemo(() => {\n    return action.icon\n      ? action.label\n      : action.label\n      ? action.label\n      : action.name;\n  }, [action]);\n\n  const icon = useMemo(() => {\n    if (isRunning) return <Loader />;\n    if (action.icon) return <Icon name={action.icon as any} />;\n    return null;\n  }, [action, isRunning]);\n\n  const handleClick = useCallback(async () => {\n    try {\n      setIsRunning(true);\n      await apiClient.callAction(action, sessionId);\n    } catch (err) {\n      toast.error(String(err));\n    } finally {\n      setIsRunning(false);\n    }\n  }, [action, sessionId, apiClient]);\n\n  const isAskingAction = askUser?.spec.type === 'action';\n  const ignore = isAskingAction && askUser?.spec.keys?.includes(action.id);\n\n  if (ignore) return null;\n\n  const button = (\n    <Button\n      id={action.id}\n      onClick={handleClick}\n      size=\"sm\"\n      variant=\"ghost\"\n      className=\"text-muted-foreground\"\n      disabled={loading || isRunning}\n    >\n      {icon}\n      {content}\n    </Button>\n  );\n\n  if (action.tooltip) {\n    return (\n      <TooltipProvider delayDuration={100}>\n        <Tooltip>\n          <TooltipTrigger asChild>{button}</TooltipTrigger>\n          <TooltipContent>\n            <p>{action.tooltip}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  } else {\n    return button;\n  }\n};\n\nexport { ActionButton };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Buttons/Actions/index.tsx",
    "content": "import { IAction } from '@chainlit/react-client';\n\nimport { ActionButton } from './ActionButton';\n\ninterface Props {\n  actions: IAction[];\n}\n\nexport default function MessageActions({ actions }: Props) {\n  return (\n    <>\n      {actions.map((a) => (\n        <ActionButton action={a} key={a.id} />\n      ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Buttons/DebugButton.tsx",
    "content": "import { BugIcon } from 'lucide-react';\n\nimport { IStep } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface DebugButtonProps {\n  debugUrl: string;\n  step: IStep;\n}\n\nconst DebugButton = ({ step, debugUrl }: DebugButtonProps) => {\n  let stepId = step.id;\n  if (stepId.startsWith('wrap_')) {\n    stepId = stepId.replace('wrap_', '');\n  }\n\n  const href = debugUrl\n    .replace('[thread_id]', step.threadId ?? '')\n    .replace('[step_id]', stepId);\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button variant=\"ghost\" size=\"icon\" className=\"h-9 w-9 p-0\" asChild>\n            <a href={href} target=\"_blank\" rel=\"noopener noreferrer\">\n              <BugIcon />\n            </a>\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>Debug in Literal AI</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n};\n\nexport { DebugButton };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Buttons/FeedbackButtons.tsx",
    "content": "import { MessageContext } from '@/contexts/MessageContext';\nimport { MessageCircle, ThumbsDown, ThumbsUp } from 'lucide-react';\nimport { useCallback, useContext, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport {\n  IStep,\n  firstUserInteraction,\n  useChatSession\n} from '@chainlit/react-client';\n\nimport { useTranslation } from '@/components/i18n/Translator';\nimport Translator from '@/components/i18n/Translator';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\ninterface FeedbackButtonsProps {\n  message: IStep;\n}\n\nexport function FeedbackButtons({ message }: FeedbackButtonsProps) {\n  const { onFeedbackUpdated, onFeedbackDeleted, showFeedbackButtons } =\n    useContext(MessageContext);\n\n  const { t } = useTranslation();\n  const [feedback, setFeedback] = useState<number | undefined>(\n    message.feedback?.value\n  );\n  const [comment, setComment] = useState<string | undefined>(\n    message.feedback?.comment\n  );\n  const [showDialog, setShowDialog] = useState<number>();\n  const [commentInput, setCommentInput] = useState<string>();\n  const firstInteraction = useRecoilValue(firstUserInteraction);\n  const { idToResume } = useChatSession();\n\n  if (!showFeedbackButtons) {\n    return null;\n  }\n\n  const handleFeedbackChange = useCallback(\n    (newFeedback?: number, newComment?: string) => {\n      if (newFeedback === undefined) {\n        if (onFeedbackDeleted && message.feedback?.id) {\n          onFeedbackDeleted(\n            message,\n            () => {\n              setFeedback(undefined);\n              setComment(undefined);\n            },\n            message.feedback.id\n          );\n        }\n      } else if (onFeedbackUpdated) {\n        onFeedbackUpdated(\n          message,\n          () => {\n            setFeedback(newFeedback);\n            setComment(newComment);\n          },\n          {\n            ...(message.feedback || {}),\n            forId: message.id,\n            threadId: message.threadId,\n            value: newFeedback,\n            comment: newComment\n          }\n        );\n      }\n    },\n    [message, onFeedbackDeleted, onFeedbackUpdated]\n  );\n\n  const handleFeedbackClick = useCallback(\n    (nextValue: number) => {\n      if (feedback === nextValue) {\n        handleFeedbackChange(undefined);\n      } else {\n        setShowDialog(nextValue);\n      }\n    },\n    [feedback, handleFeedbackChange]\n  );\n\n  const isDisabled = message.streaming || !(firstInteraction || idToResume);\n\n  return (\n    <div className=\"flex items-center\">\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              disabled={isDisabled}\n              onClick={() => handleFeedbackClick(1)}\n              className={\n                feedback === 1\n                  ? 'text-green-600 positive-feedback-on'\n                  : 'text-muted-foreground positive-feedback-off'\n              }\n            >\n              <ThumbsUp className=\"h-4 w-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <Translator path=\"chat.messages.feedback.positive\" />\n          </TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              disabled={isDisabled}\n              onClick={() => handleFeedbackClick(0)}\n              className={\n                feedback === 0\n                  ? 'text-red-600 negative-feedback-on'\n                  : 'text-muted-foreground negative-feedback-off'\n              }\n            >\n              <ThumbsDown />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <Translator path=\"chat.messages.feedback.negative\" />\n          </TooltipContent>\n        </Tooltip>\n\n        {comment && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                disabled={isDisabled}\n                onClick={() => {\n                  setShowDialog(feedback);\n                  setCommentInput(comment);\n                }}\n              >\n                <MessageCircle />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <Translator path=\"chat.messages.feedback.edit\" />\n            </TooltipContent>\n          </Tooltip>\n        )}\n      </TooltipProvider>\n\n      <Dialog\n        open={showDialog !== undefined}\n        onOpenChange={() => setShowDialog(undefined)}\n      >\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              {showDialog === 0 ? <ThumbsDown /> : <ThumbsUp />}\n              <Translator path=\"chat.messages.feedback.dialog.title\" />\n            </DialogTitle>\n          </DialogHeader>\n\n          <Textarea\n            value={commentInput}\n            onChange={(e) => setCommentInput(e.target.value || undefined)}\n            placeholder={t('chat.messages.feedback.dialog.yourFeedback')}\n            className=\"min-h-[100px]\"\n          />\n\n          <DialogFooter>\n            <Button\n              id=\"submit-feedback\"\n              onClick={() => {\n                if (showDialog !== undefined) {\n                  handleFeedbackChange(showDialog, commentInput);\n                }\n                setShowDialog(undefined);\n                setCommentInput(undefined);\n              }}\n            >\n              <Translator path=\"chat.messages.feedback.dialog.submit\" />\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Buttons/index.tsx",
    "content": "import {\n  IAction,\n  type IStep,\n  useChatMessages,\n  useConfig\n} from '@chainlit/react-client';\n\nimport CopyButton from '@/components/CopyButton';\n\nimport MessageActions from './Actions';\nimport { DebugButton } from './DebugButton';\nimport { FeedbackButtons } from './FeedbackButtons';\n\ninterface Props {\n  message: IStep;\n  actions: IAction[];\n  run?: IStep;\n  contentRef?: React.RefObject<HTMLDivElement>;\n}\n\nconst MessageButtons = ({ message, actions, run, contentRef }: Props) => {\n  const { config } = useConfig();\n  const { firstInteraction } = useChatMessages();\n\n  const isUser = message.type === 'user_message';\n  const isAsk = message.waitForAnswer;\n  const hasContent = !!message.output;\n  const showCopyButton = !!run && hasContent && !isUser && !isAsk;\n\n  const messageActions = actions.filter((a) => a.forId === message.id);\n\n  const showDebugButton =\n    !!config?.debugUrl && !!message.threadId && !!firstInteraction && !!run;\n\n  const show = showCopyButton || showDebugButton || messageActions?.length;\n\n  if (!show || message.streaming) {\n    return null;\n  }\n\n  return (\n    <div className=\"-ml-1.5 flex items-center flex-wrap\">\n      {showCopyButton ? (\n        <CopyButton content={message.output} contentRef={contentRef} />\n      ) : null}\n      {run ? <FeedbackButtons message={run} /> : null}\n      {messageActions.length ? (\n        <MessageActions actions={messageActions} />\n      ) : null}\n      {showDebugButton ? (\n        <DebugButton debugUrl={config.debugUrl!} step={message} />\n      ) : null}\n    </div>\n  );\n};\n\nexport { MessageButtons };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlineCustomElementList.tsx",
    "content": "import type { ICustomElement } from '@chainlit/react-client';\n\nimport CustomElement from '@/components/Elements/CustomElement';\n\ninterface Props {\n  items: ICustomElement[];\n}\n\nconst InlinedCustomElementList = ({ items }: Props) => (\n  <div className=\"flex flex-col gap-2\">\n    {items.map((customElement) => {\n      return <CustomElement key={customElement.id} element={customElement} />;\n    })}\n  </div>\n);\n\nexport { InlinedCustomElementList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedAudioList.tsx",
    "content": "import type { IAudioElement } from '@chainlit/react-client';\n\nimport { AudioElement } from '@/components/Elements/Audio';\n\ninterface InlinedAudioListProps {\n  items: IAudioElement[];\n}\n\nconst InlinedAudioList = ({ items }: InlinedAudioListProps) => {\n  return (\n    <div className=\"flex flex-col space-y-4\">\n      {items.map((audio, i) => (\n        <div key={i} className=\"pt-2\">\n          <AudioElement element={audio} />\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport { InlinedAudioList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedDataframeList.tsx",
    "content": "import type { IDataframeElement } from '@chainlit/react-client';\n\nimport { LazyDataframe } from '@/components/Elements/LazyDataframe';\n\ninterface Props {\n  items: IDataframeElement[];\n}\n\nconst InlinedDataframeList = ({ items }: Props) => (\n  <div className=\"flex gap-1\">\n    {items.map((element, i) => {\n      return (\n        <div key={i} className=\"max-h-[450px] w-full\">\n          <LazyDataframe element={element} />\n        </div>\n      );\n    })}\n  </div>\n);\n\nexport { InlinedDataframeList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedFileList.tsx",
    "content": "import type { IFileElement } from '@chainlit/react-client';\n\nimport { FileElement } from '@/components/Elements/File';\n\ninterface Props {\n  items: IFileElement[];\n}\n\nconst InlinedFileList = ({ items }: Props) => {\n  return (\n    <div className=\"flex items-center gap-2\">\n      {items.map((file, i) => {\n        return (\n          <div key={i}>\n            <FileElement element={file} />\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n\nexport { InlinedFileList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedImageList.tsx",
    "content": "import { ImageElement } from '@/components/Elements/Image';\nimport { QuiltedGrid } from '@/components/QuiltedGrid';\n\nimport type { IImageElement } from 'client-types/';\n\ninterface Props {\n  items: IImageElement[];\n}\n\nconst InlinedImageList = ({ items }: Props) => (\n  <QuiltedGrid\n    elements={items}\n    renderElement={(ctx) => <ImageElement element={ctx.element} />}\n  />\n);\n\nexport { InlinedImageList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedPDFList.tsx",
    "content": "import type { IPdfElement } from '@chainlit/react-client';\n\nimport { PDFElement } from '@/components/Elements/PDF';\n\ninterface Props {\n  items: IPdfElement[];\n}\n\nconst InlinedPDFList = ({ items }: Props) => (\n  <div className=\"flex flex-col gap-2\">\n    {items.map((pdf, i) => {\n      return (\n        <div key={i} className=\"h-[400px]\">\n          <PDFElement element={pdf} />\n        </div>\n      );\n    })}\n  </div>\n);\n\nexport { InlinedPDFList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedPlotlyList.tsx",
    "content": "import type { IPlotlyElement } from '@chainlit/react-client';\n\nimport { PlotlyElement } from '@/components/Elements/Plotly';\n\ninterface Props {\n  items: IPlotlyElement[];\n}\n\nconst InlinedPlotlyList = ({ items }: Props) => (\n  <div className=\"flex flex-col gap-2\">\n    {items.map((element, i) => {\n      return (\n        <div\n          key={i}\n          className=\"max-w-[600px] h-[400px]\"\n          style={{\n            maxWidth: 'fit-content',\n            height: '400px'\n          }}\n        >\n          <PlotlyElement element={element} />\n        </div>\n      );\n    })}\n  </div>\n);\n\nexport { InlinedPlotlyList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedTextList.tsx",
    "content": "import type { ITextElement } from '@chainlit/react-client';\n\nimport { TextElement } from '@/components/Elements/Text';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\n\ninterface Props {\n  items: ITextElement[];\n}\n\nconst InlinedTextList = ({ items }: Props) => (\n  <div className=\"flex flex-col gap-2\">\n    {items.map((el) => {\n      return (\n        <Card key={el.id}>\n          <CardHeader>\n            <CardTitle>{el.name}</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <TextElement element={el} />\n          </CardContent>\n        </Card>\n      );\n    })}\n  </div>\n);\n\nexport { InlinedTextList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/InlinedVideoList.tsx",
    "content": "import { VideoElement } from '@/components/Elements/Video';\n\nimport type { IVideoElement } from 'client-types/';\n\ninterface Props {\n  items: IVideoElement[];\n}\n\nconst InlinedVideoList = ({ items }: Props) => (\n  <div className=\"flex flex-col gap-2\">\n    {items.map((i) => (\n      <VideoElement key={i.id} element={i} />\n    ))}\n  </div>\n);\n\nexport { InlinedVideoList };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/InlinedElements/index.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nimport type { ElementType, IMessageElement } from '@chainlit/react-client';\n\nimport { InlinedCustomElementList } from './InlineCustomElementList';\nimport { InlinedAudioList } from './InlinedAudioList';\nimport { InlinedDataframeList } from './InlinedDataframeList';\nimport { InlinedFileList } from './InlinedFileList';\nimport { InlinedImageList } from './InlinedImageList';\nimport { InlinedPDFList } from './InlinedPDFList';\nimport { InlinedPlotlyList } from './InlinedPlotlyList';\nimport { InlinedTextList } from './InlinedTextList';\nimport { InlinedVideoList } from './InlinedVideoList';\n\ninterface Props {\n  elements: IMessageElement[];\n  className?: string;\n}\n\nconst InlinedElements = ({ elements, className }: Props) => {\n  if (!elements.length) {\n    return null;\n  }\n\n  /**\n   * Categorize the elements by element type\n   * The TypeScript dance is needed to make sure we can do elementsByType.image\n   * and get an array of IImageElement.\n   */\n  const elementsByType = elements.reduce(\n    (acc, el: IMessageElement) => {\n      if (!acc[el.type]) {\n        acc[el.type] = [];\n      }\n      const array = acc[el.type] as Extract<\n        IMessageElement,\n        { type: typeof el.type }\n      >[];\n      array.push(el);\n      return acc;\n    },\n    {} as {\n      [K in ElementType]: Extract<IMessageElement, { type: K }>[];\n    }\n  );\n\n  return (\n    <div className={cn('flex flex-col gap-4', className)}>\n      {elementsByType.custom?.length ? (\n        <InlinedCustomElementList items={elementsByType.custom} />\n      ) : null}\n      {elementsByType.image?.length ? (\n        <InlinedImageList items={elementsByType.image} />\n      ) : null}\n      {elementsByType.text?.length ? (\n        <InlinedTextList items={elementsByType.text} />\n      ) : null}\n      {elementsByType.pdf?.length ? (\n        <InlinedPDFList items={elementsByType.pdf} />\n      ) : null}\n      {elementsByType.audio?.length ? (\n        <InlinedAudioList items={elementsByType.audio} />\n      ) : null}\n      {elementsByType.video?.length ? (\n        <InlinedVideoList items={elementsByType.video} />\n      ) : null}\n      {elementsByType.file?.length ? (\n        <InlinedFileList items={elementsByType.file} />\n      ) : null}\n      {elementsByType.plotly?.length ? (\n        <InlinedPlotlyList items={elementsByType.plotly} />\n      ) : null}\n      {elementsByType.dataframe?.length ? (\n        <InlinedDataframeList items={elementsByType.dataframe} />\n      ) : null}\n    </div>\n  );\n};\n\nexport { InlinedElements };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Content/index.tsx",
    "content": "import { prepareContent } from '@/lib/message';\nimport { isEqual } from 'lodash';\nimport { forwardRef, memo, useMemo } from 'react';\n\nimport type { IMessageElement, IStep } from '@chainlit/react-client';\n\nimport { CURSOR_PLACEHOLDER } from '@/components/BlinkingCursor';\nimport { Markdown } from '@/components/Markdown';\n\nimport { InlinedElements } from './InlinedElements';\n\ntype ContentSection = 'input' | 'output';\n\nexport interface Props {\n  elements: IMessageElement[];\n  message: IStep;\n  allowHtml?: boolean;\n  latex?: boolean;\n  renderMarkdown?: boolean;\n  sections?: ContentSection[];\n}\n\nconst getMessageRenderProps = (message: IStep) => ({\n  id: message.id,\n  output: message.output,\n  input: message.input,\n  language: message.language,\n  streaming: message.streaming,\n  showInput: message.showInput,\n  type: message.type\n});\n\nconst MessageContent = memo(\n  forwardRef<HTMLDivElement, Props>(\n    (\n      { message, elements, allowHtml, latex, renderMarkdown, sections },\n      ref\n    ) => {\n      const outputContent =\n        message.streaming && message.output\n          ? message.output + CURSOR_PLACEHOLDER\n          : message.output;\n\n      const {\n        preparedContent: output,\n        inlinedElements: outputInlinedElements,\n        refElements: outputRefElements\n      } = prepareContent({\n        elements,\n        id: message.id,\n        content: outputContent,\n        language: message.language\n      });\n\n      const selectedSections = sections ?? ['input', 'output'];\n      const sectionsSet = useMemo(\n        () => new Set(selectedSections),\n        [selectedSections]\n      );\n\n      const displayInput =\n        sectionsSet.has('input') && message.input && message.showInput;\n      const displayOutput = sectionsSet.has('output');\n\n      const isMessage = message.type.includes('message');\n\n      const outputMarkdown = displayOutput ? (\n        <>\n          {!isMessage && displayInput && message.output ? (\n            <div className=\"font-medium\">Output</div>\n          ) : null}\n          <Markdown\n            allowHtml={allowHtml}\n            latex={latex}\n            renderMarkdown={renderMarkdown}\n            refElements={outputRefElements}\n          >\n            {output}\n          </Markdown>\n        </>\n      ) : null;\n\n      let inputMarkdown;\n\n      if (displayInput) {\n        const inputContent =\n          message.streaming && message.input\n            ? message.input + CURSOR_PLACEHOLDER\n            : message.input;\n        const { preparedContent: input, refElements: inputRefElements } =\n          prepareContent({\n            elements,\n            id: message.id,\n            content: inputContent,\n            language:\n              typeof message.showInput === 'string'\n                ? message.showInput\n                : undefined\n          });\n\n        inputMarkdown = (\n          <>\n            <Markdown\n              allowHtml={allowHtml}\n              latex={latex}\n              renderMarkdown={renderMarkdown}\n              refElements={inputRefElements}\n            >\n              {input}\n            </Markdown>\n          </>\n        );\n      }\n\n      const markdownContent = (\n        <div className=\"flex flex-col gap-4\">\n          {inputMarkdown}\n          {outputMarkdown}\n        </div>\n      );\n\n      return (\n        <div ref={ref} className=\"message-content w-full flex flex-col gap-2\">\n          {displayInput || (displayOutput && output) ? markdownContent : null}\n          {displayOutput ? (\n            <InlinedElements elements={outputInlinedElements} />\n          ) : null}\n        </div>\n      );\n    }\n  ),\n  (prevProps, nextProps) => {\n    return (\n      prevProps.allowHtml === nextProps.allowHtml &&\n      prevProps.latex === nextProps.latex &&\n      prevProps.renderMarkdown === nextProps.renderMarkdown &&\n      prevProps.elements === nextProps.elements &&\n      isEqual(\n        prevProps.sections ?? ['input', 'output'],\n        nextProps.sections ?? ['input', 'output']\n      ) &&\n      isEqual(\n        getMessageRenderProps(prevProps.message),\n        getMessageRenderProps(nextProps.message)\n      )\n    );\n  }\n);\n\nexport { MessageContent };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/Step.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { PropsWithChildren, useEffect, useMemo, useState } from 'react';\n\nimport type { IStep } from '@chainlit/react-client';\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger\n} from '@/components/ui/accordion';\nimport { Translator } from 'components/i18n';\n\ninterface Props {\n  step: IStep;\n  isRunning?: boolean;\n}\n\nexport default function Step({\n  step,\n  children,\n  isRunning\n}: PropsWithChildren<Props>) {\n  const using = useMemo(() => {\n    return isRunning && step.start && !step.end && !step.isError;\n  }, [step, isRunning]);\n\n  const hasContent = step.input || step.output || step.steps?.length;\n  const isError = step.isError;\n  const stepName = step.name;\n\n  const [openValue, setOpenValue] = useState<string | undefined>(\n    step.defaultOpen ? step.id : undefined\n  );\n\n  // Auto-collapse when step finishes if autoCollapse is set\n  useEffect(() => {\n    if (!using && step.autoCollapse) {\n      setOpenValue(undefined);\n    }\n  }, [using, step.autoCollapse]);\n\n  // If there's no content, just render the status without accordion\n  if (!hasContent) {\n    return (\n      <div className=\"flex flex-col flex-grow w-0\">\n        <p\n          className={cn(\n            'flex items-center gap-1 font-medium',\n            isError && 'text-red-500',\n            !using && 'text-muted-foreground',\n            using && 'loading-shimmer'\n          )}\n          id={`step-${stepName}`}\n        >\n          {using ? (\n            <>\n              <Translator path=\"chat.messages.status.using\" /> {stepName}\n            </>\n          ) : (\n            <>\n              <Translator path=\"chat.messages.status.used\" /> {stepName}\n            </>\n          )}\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col flex-grow w-0\">\n      <Accordion\n        type=\"single\"\n        collapsible\n        value={openValue}\n        onValueChange={(val) => setOpenValue(val || undefined)}\n        className=\"w-full\"\n      >\n        <AccordionItem value={step.id} className=\"border-none\">\n          <AccordionTrigger\n            className={cn(\n              'flex items-center gap-1 justify-start transition-none p-0 hover:no-underline',\n              isError && 'text-red-500',\n              !using && 'text-muted-foreground hover:text-foreground',\n              using && 'loading-shimmer'\n            )}\n            id={`step-${stepName}`}\n          >\n            {using ? (\n              <>\n                <Translator path=\"chat.messages.status.using\" /> {stepName}\n              </>\n            ) : (\n              <>\n                <Translator path=\"chat.messages.status.used\" /> {stepName}\n              </>\n            )}\n          </AccordionTrigger>\n          <AccordionContent>\n            <div className=\"flex-grow mt-4 ml-1 pl-4 border-l-2 border-primary\">\n              {children}\n            </div>\n          </AccordionContent>\n        </AccordionItem>\n      </Accordion>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/UserMessage.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { MessageContext } from 'contexts/MessageContext';\nimport { memo, useContext, useMemo, useState } from 'react';\nimport { Star } from 'lucide-react';\nimport { useSetRecoilState } from 'recoil';\n\nimport {\n  IMessageElement,\n  IStep,\n  messagesState,\n  useChatInteract,\n  useConfig\n} from '@chainlit/react-client';\n\nimport AutoResizeTextarea from '@/components/AutoResizeTextarea';\nimport { Pencil } from '@/components/icons/Pencil';\nimport { Button } from '@/components/ui/button';\nimport { Translator } from 'components/i18n';\n\nimport { InlinedElements } from './Content/InlinedElements';\n\ninterface Props {\n  message: IStep;\n  elements: IMessageElement[];\n}\n\nconst UserMessage = memo(function UserMessage({\n  message,\n  elements,\n  children\n}: React.PropsWithChildren<Props>) {\n  const { askUser, loading, editable } = useContext(MessageContext);\n  const { editMessage, toggleMessageFavorite } = useChatInteract();\n  const { config } = useConfig();\n  const setMessages = useSetRecoilState(messagesState);\n  const disabled = loading || !!askUser;\n  const isFavorite = message.metadata?.favorite === true;\n  const [isEditing, setIsEditing] = useState(false);\n  const [editValue, setEditValue] = useState('');\n\n  const inlineElements = useMemo(() => {\n    return elements.filter(\n      (el) => el.forId === message.id && el.display === 'inline'\n    );\n  }, [message.id, elements]);\n  const favoritesEnabled = !!config?.features?.favorites;\n\n  const handleEdit = () => {\n    if (editValue) {\n      setMessages((prev) => {\n        const index = prev.findIndex((m) => m.id === message.id);\n        if (index === -1) {\n          return prev;\n        }\n        const slice = prev.slice(0, index + 1);\n        slice[index].steps = [];\n        return slice;\n      });\n      setIsEditing(false);\n      editMessage({ ...message, output: editValue });\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col w-full gap-1\">\n      <InlinedElements elements={inlineElements} className=\"items-end\" />\n\n      <div className=\"flex flex-row items-center gap-1 w-full group\">\n        {!isEditing && editable && (\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"edit-message ml-auto invisible group-hover:visible\"\n            onClick={() => {\n              setEditValue(message.output);\n              setIsEditing(true);\n            }}\n            disabled={disabled}\n          >\n            <Pencil />\n          </Button>\n        )}\n        {!isEditing && favoritesEnabled && (\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className={cn(\n              'favorite-message invisible group-hover:visible',\n              isFavorite ? 'visible text-yellow-500' : 'text-muted-foreground',\n              !editable && 'ml-auto'\n            )}\n            onClick={() => toggleMessageFavorite(message)}\n            disabled={disabled}\n          >\n            <Star className={cn('h-4 w-4', isFavorite ? 'fill-current' : '')} />\n          </Button>\n        )}\n        <div\n          className={cn(\n            'px-5 py-2.5 relative bg-accent rounded-3xl',\n            inlineElements.length ? 'rounded-tr-lg' : '',\n            isEditing ? 'w-full flex-grow' : 'max-w-[70%] flex-grow-0',\n            editable ? '' : 'ml-auto'\n          )}\n        >\n          {isEditing ? (\n            <div className=\"bg-accent flex flex-col\">\n              <AutoResizeTextarea\n                id=\"edit-chat-input\"\n                autoFocus\n                value={editValue}\n                onChange={(e) => setEditValue(e.target.value)}\n                className=\"mt-1 bg-transparent placeholder:text-base placeholder:font-medium text-base\"\n                maxHeight={250}\n              />\n              <div className=\"flex justify-end gap-4\">\n                <Button variant=\"ghost\" onClick={() => setIsEditing(false)}>\n                  <Translator path=\"common.actions.cancel\" />\n                </Button>\n                <Button\n                  className=\"confirm-edit\"\n                  disabled={disabled}\n                  onClick={handleEdit}\n                >\n                  <Translator path=\"common.actions.confirm\" />\n                </Button>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex flex-col\">\n              {message.command ? (\n                <div className=\"font-bold text-[#08f] command-span\">\n                  {message.command}\n                </div>\n              ) : null}\n              {children}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n});\n\nexport default UserMessage;\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/Message/index.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { MessageContext } from 'contexts/MessageContext';\nimport { memo, useContext, useMemo, useRef } from 'react';\n\nimport {\n  type IAction,\n  type IMessageElement,\n  type IStep\n} from '@chainlit/react-client';\n\nimport { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';\n\nimport { Messages } from '..';\nimport { AskActionButtons } from './AskActionButtons';\nimport { AskFileButton } from './AskFileButton';\nimport { MessageAvatar } from './Avatar';\nimport { MessageButtons } from './Buttons';\nimport { MessageContent } from './Content';\nimport Step from './Step';\nimport UserMessage from './UserMessage';\n\ninterface Props {\n  message: IStep;\n  elements: IMessageElement[];\n  actions: IAction[];\n  indent: number;\n  isRunning?: boolean;\n  isScorable?: boolean;\n  scorableRun?: IStep;\n}\n\nconst EMPTY_ELEMENTS: IMessageElement[] = [];\n\nconst Message = memo(\n  ({\n    message,\n    elements,\n    actions,\n    isRunning,\n    indent,\n    isScorable,\n    scorableRun\n  }: Props) => {\n    const { allowHtml, cot, latex, renderUserMarkdown, onError } =\n      useContext(MessageContext);\n    const layoutMaxWidth = useLayoutMaxWidth();\n    const contentRef = useRef<HTMLDivElement>(null);\n    const isUserMessage = message.type === 'user_message';\n    const isStep = !message.type.includes('message');\n    // Only keep tool calls if Chain of Thought is tool_call\n    const toolCallSkip =\n      isStep && cot === 'tool_call' && message.type !== 'tool';\n\n    const hiddenSkip = isStep && cot === 'hidden';\n\n    const skip = toolCallSkip || hiddenSkip;\n    const showInputSection = Boolean(message.input && message.showInput);\n    const shouldRenderOutput = !showInputSection || Boolean(message.output);\n\n    const userMessageContent = useMemo(\n      () => (\n        <MessageContent\n          elements={EMPTY_ELEMENTS}\n          message={message}\n          allowHtml={allowHtml}\n          latex={latex}\n          renderMarkdown={renderUserMarkdown}\n        />\n      ),\n      [message, allowHtml, latex]\n    );\n\n    if (skip) {\n      if (!message.steps) {\n        return null;\n      }\n      return (\n        <Messages\n          messages={message.steps}\n          elements={elements}\n          actions={actions}\n          indent={indent}\n          isRunning={isRunning}\n          scorableRun={scorableRun}\n        />\n      );\n    }\n\n    return (\n      <>\n        <div data-step-type={message.type} className=\"step py-2\">\n          <div\n            className=\"flex flex-col\"\n            style={{\n              maxWidth: indent ? '100%' : layoutMaxWidth\n            }}\n          >\n            <div\n              className={cn('flex flex-grow pb-2')}\n              id={`step-${message.id}`}\n            >\n              {/* User message is displayed differently */}\n              {isUserMessage ? (\n                <div className=\"flex flex-col flex-grow max-w-full\">\n                  <UserMessage message={message} elements={elements}>\n                    {userMessageContent}\n                  </UserMessage>\n                </div>\n              ) : (\n                <div className=\"ai-message flex gap-4 w-full\">\n                  {!isStep || !indent ? (\n                    <MessageAvatar\n                      author={message.metadata?.avatarName || message.name}\n                      isError={message.isError}\n                    />\n                  ) : null}\n                  {/* Display the step and its children */}\n                  {isStep ? (\n                    <Step step={message} isRunning={isRunning}>\n                      {showInputSection ? (\n                        <MessageContent\n                          elements={elements}\n                          message={message}\n                          allowHtml={allowHtml}\n                          latex={latex}\n                          renderMarkdown={true}\n                          sections={['input']}\n                        />\n                      ) : null}\n                      {message.steps ? (\n                        <Messages\n                          messages={message.steps.filter(\n                            (s) => !s.type.includes('message')\n                          )}\n                          elements={elements}\n                          actions={actions}\n                          indent={indent + 1}\n                          isRunning={isRunning}\n                        />\n                      ) : null}\n                      {shouldRenderOutput ? (\n                        <MessageContent\n                          ref={contentRef}\n                          elements={elements}\n                          message={message}\n                          allowHtml={allowHtml}\n                          latex={latex}\n                          renderMarkdown={true}\n                          sections={showInputSection ? ['output'] : undefined}\n                        />\n                      ) : null}\n                      <MessageButtons\n                        message={message}\n                        actions={actions}\n                        contentRef={contentRef}\n                      />\n                    </Step>\n                  ) : (\n                    // Display an assistant message\n                    <div className=\"flex flex-col items-start min-w-[150px] flex-grow gap-2\">\n                      <MessageContent\n                        ref={contentRef}\n                        elements={elements}\n                        message={message}\n                        allowHtml={allowHtml}\n                        latex={latex}\n                        renderMarkdown={true}\n                      />\n\n                      <AskFileButton messageId={message.id} onError={onError} />\n                      <AskActionButtons\n                        actions={actions}\n                        messageId={message.id}\n                      />\n\n                      <MessageButtons\n                        message={message}\n                        actions={actions}\n                        run={\n                          scorableRun && isScorable ? scorableRun : undefined\n                        }\n                        contentRef={contentRef}\n                      />\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n        {/* Make sure the child assistant messages of a step are displayed at the root level. */}\n        {message.steps && isStep ? (\n          <Messages\n            messages={message.steps.filter((s) => s.type.includes('message'))}\n            elements={elements}\n            actions={actions}\n            indent={0}\n            isRunning={isRunning}\n            scorableRun={scorableRun}\n          />\n        ) : null}\n        {/* Display the child steps if the message is not a step (usually a user message). */}\n        {message.steps && !isStep ? (\n          <Messages\n            messages={message.steps}\n            elements={elements}\n            actions={actions}\n            indent={indent}\n            isRunning={isRunning}\n          />\n        ) : null}\n      </>\n    );\n  }\n);\n\nexport { Message };\n"
  },
  {
    "path": "frontend/src/components/chat/Messages/index.tsx",
    "content": "import { MessageContext } from 'contexts/MessageContext';\nimport React, { memo, useContext, useMemo } from 'react';\n\nimport {\n  type IAction,\n  type IMessageElement,\n  type IStep\n} from '@chainlit/react-client';\n\nimport BlinkingCursor from '@/components/BlinkingCursor';\n\nimport { Message } from './Message';\n\ninterface Props {\n  messages: IStep[];\n  elements: IMessageElement[];\n  actions: IAction[];\n  indent: number;\n  isRunning?: boolean;\n  scorableRun?: IStep;\n}\n\nconst CL_RUN_NAMES = ['on_chat_start', 'on_message', 'on_audio_end'];\n\nconst hasActiveToolStep = (step: IStep): boolean => {\n  return (\n    step.steps?.some(\n      (s) =>\n        (s.type === 'tool' && s.start && !s.end && !s.isError) ||\n        s.type.includes('message') ||\n        hasActiveToolStep(s)\n    ) || false\n  );\n};\n\nconst hasAssistantMessage = (step: IStep): boolean => {\n  return (\n    step.steps?.some(\n      (s) => s.type === 'assistant_message' || hasAssistantMessage(s)\n    ) || false\n  );\n};\n\nconst Messages = memo(\n  ({ messages, elements, actions, indent, isRunning, scorableRun }: Props) => {\n    const messageContext = useContext(MessageContext);\n\n    const lastAssistantMessage = useMemo(() => {\n      return messages.findLast((m) => m.type === 'assistant_message');\n    }, [messages]);\n\n    const lastScorableAssistantMessage = useMemo(() => {\n      return scorableRun?.steps?.findLast(\n        (m) => m.type === 'assistant_message'\n      );\n    }, [scorableRun]);\n\n    return (\n      <>\n        {messages.map((m) => {\n          // Handle chainlit runs\n          if (CL_RUN_NAMES.includes(m.name)) {\n            const isRunning = !m.end && !m.isError && messageContext.loading;\n            const isToolCallCoT =\n              messageContext.cot === 'tool_call' ||\n              messageContext.cot === 'full';\n            const isHiddenCoT = messageContext.cot === 'hidden';\n\n            const showToolCoTLoader = isToolCallCoT\n              ? isRunning && !hasActiveToolStep(m)\n              : false;\n\n            const showHiddenCoTLoader = isHiddenCoT\n              ? isRunning && !hasAssistantMessage(m)\n              : false;\n            // Ignore on_chat_start for scorable run\n            const scorableRun =\n              !isRunning && m.name !== 'on_chat_start' ? m : undefined;\n            return (\n              <React.Fragment key={m.id}>\n                {m.steps?.length ? (\n                  <Messages\n                    messages={m.steps}\n                    elements={elements}\n                    actions={actions}\n                    indent={indent}\n                    isRunning={isRunning}\n                    scorableRun={scorableRun}\n                  />\n                ) : null}\n                {(showToolCoTLoader || showHiddenCoTLoader) &&\n                m.name !== 'on_chat_start' ? (\n                  <BlinkingCursor />\n                ) : null}\n              </React.Fragment>\n            );\n          } else {\n            // Score the current run\n            const _scorableRun = m.type === 'run' ? m : scorableRun;\n            // The message is scorable if it is the last assistant message of the run\n\n            const isRunLastAssistantMessage =\n              m.type === 'run' ? false : m === lastScorableAssistantMessage;\n\n            const isLastAssistantMessage = m === lastAssistantMessage;\n\n            const isScorable =\n              isRunLastAssistantMessage || isLastAssistantMessage;\n\n            return (\n              <Message\n                message={m}\n                elements={elements}\n                actions={actions}\n                key={m.id}\n                indent={indent}\n                isRunning={isRunning}\n                scorableRun={_scorableRun}\n                isScorable={isScorable}\n              />\n            );\n          }\n        })}\n      </>\n    );\n  }\n);\n\nexport { Messages };\n"
  },
  {
    "path": "frontend/src/components/chat/MessagesContainer/index.tsx",
    "content": "import { MessageContext } from '@/contexts/MessageContext';\nimport { useCallback, useContext, useMemo } from 'react';\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  IFeedback,\n  IMessageElement,\n  IStep,\n  messagesState,\n  sessionIdState,\n  sideViewState,\n  updateMessageById,\n  useChatData,\n  useChatInteract,\n  useChatMessages,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { Messages } from '@/components/chat/Messages';\nimport { useTranslation } from 'components/i18n/Translator';\n\ninterface Props {\n  navigate?: (to: string) => void;\n}\n\nconst MessagesContainer = ({ navigate }: Props) => {\n  const apiClient = useContext(ChainlitContext);\n  const { config } = useConfig();\n  const { elements, askUser, loading, actions } = useChatData();\n  const { messages } = useChatMessages();\n  const { uploadFile: _uploadFile } = useChatInteract();\n  const setMessages = useSetRecoilState(messagesState);\n  const setSideView = useSetRecoilState(sideViewState);\n  const sessionId = useRecoilValue(sessionIdState);\n\n  const { t } = useTranslation();\n\n  const uploadFile = useCallback(\n    (file: File, onProgress: (progress: number) => void, parentId?: string) => {\n      return _uploadFile(file, onProgress, parentId);\n    },\n    [_uploadFile]\n  );\n\n  const onFeedbackUpdated = useCallback(\n    async (message: IStep, onSuccess: () => void, feedback: IFeedback) => {\n      toast.promise(apiClient.setFeedback(feedback, sessionId), {\n        loading: t('chat.messages.feedback.status.updating'),\n        success: (res) => {\n          setMessages((prev) =>\n            updateMessageById(prev, message.id, {\n              ...message,\n              feedback: {\n                ...feedback,\n                id: res.feedbackId\n              }\n            })\n          );\n          onSuccess();\n          return t('chat.messages.feedback.status.updated');\n        },\n        error: (err) => {\n          return <span>{err.message}</span>;\n        }\n      });\n    },\n    []\n  );\n\n  const onFeedbackDeleted = useCallback(\n    async (message: IStep, onSuccess: () => void, feedbackId: string) => {\n      toast.promise(apiClient.deleteFeedback(feedbackId), {\n        loading: t('chat.messages.feedback.status.updating'),\n        success: () => {\n          setMessages((prev) =>\n            updateMessageById(prev, message.id, {\n              ...message,\n              feedback: undefined\n            })\n          );\n          onSuccess();\n          return t('chat.messages.feedback.status.updated');\n        },\n        error: (err) => {\n          return <span>{err.message}</span>;\n        }\n      });\n    },\n    []\n  );\n\n  const onElementRefClick = useCallback(\n    (element: IMessageElement) => {\n      if (\n        element.display === 'side' ||\n        (element.display === 'page' && !navigate)\n      ) {\n        setSideView({ title: element.name, elements: [element] });\n        return;\n      }\n\n      let path = `/element/${element.id}`;\n\n      if (element.threadId) {\n        path += `?thread=${element.threadId}`;\n      }\n\n      return navigate?.(element.display === 'page' ? path : '#');\n    },\n    [setSideView, navigate]\n  );\n\n  const onError = useCallback((error: string) => toast.error(error), [toast]);\n\n  const enableFeedback = !!config?.dataPersistence;\n\n  // Memoize the context object since it's created on each render.\n  // This prevents unnecessary re-renders of children components when no props have changed.\n  const memoizedContext = useMemo(() => {\n    return {\n      uploadFile,\n      askUser,\n      allowHtml: config?.features?.unsafe_allow_html,\n      latex: config?.features?.latex,\n      renderUserMarkdown: config?.features?.user_message_markdown,\n      editable: !!config?.features.edit_message,\n      loading,\n      showFeedbackButtons: enableFeedback,\n      uiName: config?.ui?.name || '',\n      cot: config?.ui?.cot || 'hidden',\n      onElementRefClick,\n      onError,\n      onFeedbackUpdated,\n      onFeedbackDeleted\n    };\n  }, [\n    askUser,\n    enableFeedback,\n    loading,\n    config?.ui?.name,\n    config?.ui?.cot,\n    config?.features?.unsafe_allow_html,\n    config?.features?.user_message_markdown,\n    onElementRefClick,\n    onError,\n    onFeedbackUpdated\n  ]);\n\n  return (\n    <MessageContext.Provider value={memoizedContext}>\n      <Messages\n        indent={0}\n        isRunning={loading}\n        messages={messages}\n        elements={elements}\n        actions={actions}\n      />\n    </MessageContext.Provider>\n  );\n};\n\nexport default MessagesContainer;\n"
  },
  {
    "path": "frontend/src/components/chat/ScrollContainer.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { ArrowDown } from 'lucide-react';\nimport {\n  MutableRefObject,\n  useCallback,\n  useEffect,\n  useRef,\n  useState\n} from 'react';\n\nimport { useChatMessages } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\n\ninterface Props {\n  autoScrollUserMessage?: boolean;\n  autoScrollAssistantMessage?: boolean;\n  autoScrollRef?: MutableRefObject<boolean>;\n  children: React.ReactNode;\n  className?: string;\n}\n\nexport default function ScrollContainer({\n  autoScrollRef,\n  autoScrollUserMessage,\n  autoScrollAssistantMessage,\n  children,\n  className\n}: Props) {\n  const ref = useRef<HTMLDivElement>(null);\n  const spacerRef = useRef<HTMLDivElement>(null);\n  const lastUserMessageRef = useRef<HTMLDivElement | null>(null);\n  const { messages } = useChatMessages();\n  const [showScrollButton, setShowScrollButton] = useState(false);\n  const [isScrolling, setIsScrolling] = useState(false);\n\n  // Calculate and update spacer height\n  const updateSpacerHeight = useCallback(() => {\n    if (!ref.current) return;\n\n    if (autoScrollUserMessage && lastUserMessageRef.current) {\n      const containerHeight = ref.current.clientHeight;\n      const lastMessageHeight = lastUserMessageRef.current.offsetHeight;\n\n      // Calculate the height of all elements after the last user message\n      let afterMessagesHeight = 0;\n      let currentElement = lastUserMessageRef.current.nextElementSibling;\n\n      // Iterate through all siblings after the last user message\n      while (currentElement && currentElement !== spacerRef.current) {\n        afterMessagesHeight += (currentElement as HTMLElement).offsetHeight;\n        currentElement = currentElement.nextElementSibling;\n      }\n\n      // Position the last user message at the top with some padding\n      // Subtract both the message height and the height of any messages after it\n      const newSpacerHeight =\n        containerHeight - lastMessageHeight - afterMessagesHeight - 32;\n\n      // Only set a positive spacer height\n      if (spacerRef.current) {\n        spacerRef.current.style.height = `${Math.max(0, newSpacerHeight)}px`;\n      }\n\n      // Scroll to position the message at the top\n      if (afterMessagesHeight === 0) {\n        scrollToPosition();\n      } else if (autoScrollAssistantMessage && autoScrollRef?.current) {\n        ref.current.scrollTop = ref.current.scrollHeight;\n      }\n    } else if (autoScrollAssistantMessage && autoScrollRef?.current) {\n      ref.current.scrollTop = ref.current.scrollHeight;\n    }\n  }, [autoScrollUserMessage, autoScrollAssistantMessage, autoScrollRef]);\n\n  // Find and set a ref to the last user message element\n  useEffect(() => {\n    if (!ref.current) return;\n\n    if (messages.length === 0 && spacerRef.current) {\n      spacerRef.current.style.height = `0px`;\n      return;\n    }\n\n    // Get all message elements\n    const userMessages = ref.current.querySelectorAll(\n      '[data-step-type=\"user_message\"]'\n    );\n    if (userMessages.length > 0) {\n      const lastUserMessage = userMessages[\n        userMessages.length - 1\n      ] as HTMLDivElement;\n      lastUserMessageRef.current = lastUserMessage;\n\n      // Update spacer height when last user message is found\n      updateSpacerHeight();\n    }\n  }, [messages, updateSpacerHeight]);\n\n  // Add window resize listener to update spacer height\n  useEffect(() => {\n    if (!autoScrollUserMessage) return;\n\n    const handleResize = () => {\n      updateSpacerHeight();\n    };\n\n    window.addEventListener('resize', handleResize);\n\n    // Initial update\n    updateSpacerHeight();\n\n    return () => {\n      window.removeEventListener('resize', handleResize);\n    };\n  }, [autoScrollUserMessage, updateSpacerHeight]);\n\n  // Check scroll position on mount\n  useEffect(() => {\n    if (!ref.current) return;\n\n    setTimeout(() => {\n      if (!ref.current) return;\n\n      const { scrollTop, scrollHeight, clientHeight } = ref.current;\n      const atBottom = scrollTop + clientHeight >= scrollHeight - 10;\n      setShowScrollButton(!atBottom);\n    }, 500);\n  }, []);\n\n  const checkScrollEnd = () => {\n    if (!ref.current) return;\n\n    const prevScrollTop = ref.current.scrollTop;\n\n    setTimeout(() => {\n      if (!ref.current) return;\n\n      const currentScrollTop = ref.current.scrollTop;\n      if (currentScrollTop === prevScrollTop) {\n        setIsScrolling(false);\n\n        const { scrollTop, scrollHeight, clientHeight } = ref.current;\n        const atBottom = scrollTop + clientHeight >= scrollHeight - 10;\n        setShowScrollButton(!atBottom);\n      } else {\n        checkScrollEnd();\n      }\n    }, 100);\n  };\n\n  const scrollToBottom = () => {\n    if (!ref.current) return;\n\n    setIsScrolling(true);\n    ref.current.scrollTo({\n      top: ref.current.scrollHeight,\n      behavior: 'smooth'\n    });\n\n    if (autoScrollRef) {\n      autoScrollRef.current = true;\n    }\n\n    setShowScrollButton(false);\n    checkScrollEnd();\n  };\n\n  const scrollToPosition = () => {\n    if (!ref.current || !lastUserMessageRef.current) return;\n\n    setIsScrolling(true);\n    // Scroll to position the last user message at the top with some padding\n    const scrollPosition = lastUserMessageRef.current.offsetTop - 20;\n\n    ref.current.scrollTo({\n      top: scrollPosition,\n      behavior: 'smooth'\n    });\n\n    setShowScrollButton(false);\n    checkScrollEnd();\n  };\n\n  const handleScroll = () => {\n    if (!ref.current || isScrolling) return;\n    const { scrollTop, scrollHeight, clientHeight } = ref.current;\n    const atBottom = scrollTop + clientHeight >= scrollHeight - 10;\n\n    if (autoScrollRef) {\n      autoScrollRef.current = atBottom;\n    }\n\n    setShowScrollButton(!atBottom);\n  };\n\n  return (\n    <div className=\"relative flex flex-col flex-grow overflow-y-auto\">\n      <div\n        ref={ref}\n        className={cn('flex flex-col flex-grow overflow-y-auto', className)}\n        onScroll={handleScroll}\n      >\n        {children}\n        {/* Dynamic spacer to position the last user message at the top */}\n        <div ref={spacerRef} className=\"flex-shrink-0\" />\n      </div>\n\n      {showScrollButton ? (\n        <div className=\"absolute bottom-4 left-0 right-0 flex justify-center\">\n          <Button\n            size=\"icon\"\n            variant=\"outline\"\n            className=\"rounded-full\"\n            onClick={scrollToBottom}\n          >\n            <ArrowDown className=\"size-4\" />\n          </Button>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/ScrollDownButton.tsx",
    "content": "import { ArrowDown } from 'lucide-react';\n\nimport { Button } from '@/components/ui/button';\n\ninterface Props {\n  onClick?: () => void;\n}\n\nexport default function ScrollDownButton({ onClick }: Props) {\n  return (\n    <Button\n      size=\"icon\"\n      variant=\"outline\"\n      className=\"z-1 absolute -top-4 mx-auto rounded-full -translate-y-full\"\n      onClick={onClick}\n    >\n      <ArrowDown className=\"!size-4\" />\n    </Button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Starter.tsx",
    "content": "import { useCallback, useContext } from 'react';\nimport { useRecoilValue } from 'recoil';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport {\n  ChainlitContext,\n  IStarter,\n  IStep,\n  modesState,\n  useAuth,\n  useChatData,\n  useChatInteract\n} from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\n\nimport { persistentCommandState } from '@/state/chat';\n\ninterface StarterProps {\n  starter: IStarter;\n}\n\nexport default function Starter({ starter }: StarterProps) {\n  const apiClient = useContext(ChainlitContext);\n  const selectedCommand = useRecoilValue(persistentCommandState);\n  const modes = useRecoilValue(modesState);\n  const { sendMessage } = useChatInteract();\n  const { loading, connected } = useChatData();\n  const { user } = useAuth();\n\n  const disabled = loading || !connected;\n\n  const onSubmit = useCallback(async () => {\n    // Build modes dict: only include modes that have selections\n    // (same logic as MessageComposer)\n    const modesDict: Record<string, string> = {};\n    modes.forEach((mode) => {\n      const defaultOpt = mode.options.find((opt) => opt.default);\n      const selectedId = defaultOpt?.id || mode.options[0]?.id;\n      if (selectedId) {\n        modesDict[mode.id] = selectedId;\n      }\n    });\n\n    const message: IStep = {\n      threadId: '',\n      id: uuidv4(),\n      command: starter.command ?? selectedCommand?.id,\n      modes: Object.keys(modesDict).length > 0 ? modesDict : undefined,\n      name: user?.identifier || 'User',\n      type: 'user_message',\n      output: starter.message,\n      createdAt: new Date().toISOString(),\n      metadata: { location: window.location.href }\n    };\n\n    sendMessage(message, []);\n  }, [user, selectedCommand, modes, sendMessage, starter]);\n\n  return (\n    <Button\n      id={`starter-${starter.label.trim().toLowerCase().replaceAll(' ', '-')}`}\n      variant=\"outline\"\n      className=\"w-fit justify-start rounded-3xl\"\n      disabled={disabled}\n      onClick={onSubmit}\n    >\n      <div className=\"flex gap-2\">\n        {starter.icon ? (\n          <img\n            className=\"h-5 w-5 rounded-md\"\n            src={\n              starter.icon?.startsWith('/public')\n                ? apiClient.buildEndpoint(starter.icon)\n                : starter.icon\n            }\n            alt={starter.label}\n          />\n        ) : null}\n        <p className=\"text-sm text-muted-foreground truncate\">\n          {starter.label}\n        </p>\n      </div>\n    </Button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/StarterCategory.tsx",
    "content": "import { IStarterCategory } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\n\ninterface Props {\n  category: IStarterCategory;\n  isSelected: boolean;\n  onClick: () => void;\n}\n\nexport default function StarterCategory({ category, isSelected, onClick }: Props) {\n  return (\n    <Button\n      variant={isSelected ? 'default' : 'outline'}\n      className=\"rounded-full gap-2\"\n      onClick={onClick}\n    >\n      {category.icon && (\n        <img className=\"h-4 w-4\" src={category.icon} alt=\"\" />\n      )}\n      {category.label}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/Starters.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { useMemo, useState } from 'react';\n\nimport { useChatSession, useConfig } from '@chainlit/react-client';\n\nimport Starter from './Starter';\nimport StarterCategory from './StarterCategory';\n\ninterface Props {\n  className?: string;\n}\n\nexport default function Starters({ className }: Props) {\n  const { chatProfile } = useChatSession();\n  const { config } = useConfig();\n  const [selectedCategory, setSelectedCategory] = useState<string | null>(null);\n\n  const starters = useMemo(() => {\n    if (chatProfile) {\n      const selectedChatProfile = config?.chatProfiles.find(\n        (profile) => profile.name === chatProfile\n      );\n      if (selectedChatProfile?.starters) {\n        return selectedChatProfile.starters;\n      }\n    }\n    return config?.starters;\n  }, [config, chatProfile]);\n\n  const starterCategories = config?.starterCategories;\n\n  if (starterCategories?.length) {\n    const selectedCategoryData = starterCategories.find(\n      (cat) => cat.label === selectedCategory\n    );\n\n    return (\n      <div\n        id=\"starters\"\n        className={cn('flex flex-col gap-4 items-center', className)}\n      >\n        <div className=\"flex gap-2 justify-center flex-wrap\">\n          {starterCategories.map((category) => (\n            <StarterCategory\n              key={category.label}\n              category={category}\n              isSelected={selectedCategory === category.label}\n              onClick={() =>\n                setSelectedCategory(\n                  selectedCategory === category.label ? null : category.label\n                )\n              }\n            />\n          ))}\n        </div>\n        {selectedCategoryData?.starters?.length && (\n          <div className=\"flex gap-2 justify-center flex-wrap\">\n            {selectedCategoryData.starters.map((starter) => (\n              <Starter key={starter.label} starter={starter} />\n            ))}\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  if (!starters?.length) return null;\n\n  return (\n    <div\n      id=\"starters\"\n      className={cn('flex gap-2 justify-center flex-wrap', className)}\n    >\n      {starters.map((starter, i) => (\n        <Starter key={i} starter={starter} />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/WelcomeScreen.tsx",
    "content": "import { cn, hasMessage } from '@/lib/utils';\nimport {\n  MutableRefObject,\n  useContext,\n  useEffect,\n  useMemo,\n  useState\n} from 'react';\n\nimport {\n  ChainlitContext,\n  FileSpec,\n  useChatMessages,\n  useChatSession,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { Logo } from '@/components/Logo';\nimport { Markdown } from '@/components/Markdown';\n\nimport MessageComposer from './MessageComposer';\nimport Starters from './Starters';\n\ninterface Props {\n  fileSpec: FileSpec;\n  onFileUpload: (payload: File[]) => void;\n  onFileUploadError: (error: string) => void;\n  autoScrollRef: MutableRefObject<boolean>;\n}\n\nexport default function WelcomeScreen(props: Props) {\n  const apiClient = useContext(ChainlitContext);\n  const { config } = useConfig();\n  const { chatProfile } = useChatSession();\n  const { messages } = useChatMessages();\n  const [isVisible, setIsVisible] = useState(false);\n\n  const chatProfiles = config?.chatProfiles;\n\n  const allowHtml = config?.features?.unsafe_allow_html;\n  const latex = config?.features?.latex;\n\n  useEffect(() => {\n    setIsVisible(true);\n  }, []);\n\n  const logo = useMemo(() => {\n    if (chatProfile && chatProfiles) {\n      const currentChatProfile = chatProfiles.find(\n        (cp) => cp.name === chatProfile\n      );\n      if (currentChatProfile?.icon) {\n        return (\n          <div className=\"flex flex-col gap-2 mb-2 items-center\">\n            <img\n              className=\"h-16 w-16 rounded-full\"\n              src={\n                currentChatProfile?.icon.startsWith('/public')\n                  ? apiClient.buildEndpoint(currentChatProfile?.icon)\n                  : currentChatProfile?.icon\n              }\n            />\n            {currentChatProfile?.markdown_description ? (\n              <Markdown\n                allowHtml={allowHtml}\n                latex={latex}\n                renderMarkdown={true}\n              >\n                {currentChatProfile.markdown_description}\n              </Markdown>\n            ) : null}\n          </div>\n        );\n      }\n    }\n\n    return <Logo className=\"w-[200px] mb-2\" />;\n  }, [chatProfiles, chatProfile]);\n\n  if (hasMessage(messages)) return null;\n\n  return (\n    <div\n      id=\"welcome-screen\"\n      className={cn(\n        'flex flex-col -mt-[60px] gap-4 w-full flex-grow items-center justify-center welcome-screen mx-auto transition-opacity duration-500 opacity-0 delay-100',\n        isVisible && 'opacity-100'\n      )}\n    >\n      {logo}\n      <MessageComposer {...props} />\n      <Starters />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/chat/index.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport {\n  threadHistoryState,\n  useAuth,\n  useChatData,\n  useChatInteract,\n  useChatMessages,\n  useConfig\n} from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { TaskList } from '@/components/Tasklist';\nimport { Translator } from 'components/i18n';\nimport { useTranslation } from 'components/i18n/Translator';\n\nimport { useUpload } from '@/hooks/useUpload';\nimport { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';\n\nimport { IAttachment, attachmentsState } from 'state/chat';\n\nimport { ErrorBoundary } from '../ErrorBoundary';\nimport ChatFooter from './Footer';\nimport MessagesContainer from './MessagesContainer';\nimport ScrollContainer from './ScrollContainer';\nimport WelcomeScreen from './WelcomeScreen';\n\nconst Chat = () => {\n  const { user } = useAuth();\n  const { config } = useConfig();\n  const setAttachments = useSetRecoilState(attachmentsState);\n  const setThreads = useSetRecoilState(threadHistoryState);\n\n  const autoScrollRef = useRef(true);\n  const { error, disabled, callFn } = useChatData();\n  const { uploadFile } = useChatInteract();\n  const uploadFileRef = useRef(uploadFile);\n  const navigate = useNavigate();\n\n  // Update file upload MIME types to use standard format following Mozilla's guidelines: @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers\n  // Instead of using '*/*' which may cause MIME type warnings, we specify exact MIME type categories:\n  // - 'application/*' - for general files\n  // - 'audio/*' - for audio files\n  // - 'image/*' - for image files\n  // - 'text/*' - for text files\n  // - 'video/*' - for video files\n  // This provides better type safety and clearer file type expectations.\n  const fileSpec = useMemo(\n    () => ({\n      max_size_mb:\n        config?.features?.spontaneous_file_upload?.max_size_mb || 500,\n      max_files: config?.features?.spontaneous_file_upload?.max_files || 20,\n      accept: config?.features?.spontaneous_file_upload?.accept || {\n        'application/*': [], // All application files\n        'audio/*': [], // All audio files\n        'image/*': [], // All image files\n        'text/*': [], // All text files\n        'video/*': [] // All video files\n      }\n    }),\n    [config]\n  );\n\n  const { t } = useTranslation();\n  const layoutMaxWidth = useLayoutMaxWidth();\n\n  useEffect(() => {\n    if (callFn) {\n      const event = new CustomEvent('chainlit-call-fn', {\n        detail: callFn\n      });\n      window.dispatchEvent(event);\n    }\n  }, [callFn]);\n\n  useEffect(() => {\n    uploadFileRef.current = uploadFile;\n  }, [uploadFile]);\n\n  const onFileUpload = useCallback(\n    (payloads: File[]) => {\n      const attachements: IAttachment[] = payloads.map((file) => {\n        const id = uuidv4();\n\n        const { xhr, promise } = uploadFileRef.current(file, (progress) => {\n          setAttachments((prev) =>\n            prev.map((attachment) => {\n              if (attachment.id === id) {\n                return {\n                  ...attachment,\n                  uploadProgress: progress\n                };\n              }\n              return attachment;\n            })\n          );\n        });\n\n        promise\n          .then((res) => {\n            setAttachments((prev) =>\n              prev.map((attachment) => {\n                if (attachment.id === id) {\n                  return {\n                    ...attachment,\n                    // Update with the server ID\n                    serverId: res.id,\n                    uploaded: true,\n                    uploadProgress: 100,\n                    cancel: undefined\n                  };\n                }\n                return attachment;\n              })\n            );\n          })\n          .catch((error) => {\n            toast.error(\n              `${t('chat.fileUpload.errors.failed')} ${file.name}: ${\n                typeof error === 'object' && error !== null\n                  ? error.message ?? error\n                  : error\n              }`\n            );\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          });\n\n        return {\n          id,\n          type: file.type,\n          name: file.name,\n          size: file.size,\n          uploadProgress: 0,\n          file,\n          cancel: () => {\n            toast.info(`${t('chat.fileUpload.errors.cancelled')} ${file.name}`);\n            xhr.abort();\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          },\n          remove: () => {\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          }\n        };\n      });\n      setAttachments((prev) => prev.concat(attachements));\n    },\n    [uploadFile]\n  );\n\n  const onFileUploadError = useCallback(\n    (error: string) => toast.error(error),\n    [toast]\n  );\n\n  const upload = useUpload({\n    spec: fileSpec,\n    onResolved: onFileUpload,\n    onError: onFileUploadError,\n    options: { noClick: true }\n  });\n\n  const { threadId } = useChatMessages();\n\n  useEffect(() => {\n    const currentPage = new URL(window.location.href);\n    if (\n      user &&\n      config?.dataPersistence &&\n      threadId &&\n      currentPage.pathname === '/'\n    ) {\n      navigate(`/thread/${threadId}`);\n    } else {\n      setThreads((prev) => ({\n        ...prev,\n        currentThreadId: threadId\n      }));\n    }\n  }, []);\n\n  const enableAttachments =\n    !disabled && config?.features?.spontaneous_file_upload?.enabled;\n  return (\n    <div\n      {...(enableAttachments\n        ? upload.getRootProps({ className: 'dropzone' })\n        : {})}\n      // Disable the onFocus and onBlur events in react-dropzone to avoid interfering with child trigger events\n      onBlur={undefined}\n      onFocus={undefined}\n      className=\"flex w-full h-full flex-col relative\"\n    >\n      {enableAttachments ? (\n        <input id=\"#upload-drop-input\" {...upload.getInputProps()} />\n      ) : null}\n\n      {error ? (\n        <div className=\"w-full mx-auto my-2\">\n          <Alert className=\"mx-2\" id=\"session-error\" variant=\"error\">\n            <Translator path=\"common.status.error.serverConnection\" />\n          </Alert>\n        </div>\n      ) : null}\n      <ErrorBoundary>\n        <ScrollContainer\n          autoScrollUserMessage={config?.features?.user_message_autoscroll}\n          autoScrollAssistantMessage={\n            config?.features?.assistant_message_autoscroll\n          }\n          autoScrollRef={autoScrollRef}\n        >\n          <div\n            className=\"flex flex-col mx-auto w-full flex-grow p-4\"\n            style={{\n              maxWidth: layoutMaxWidth\n            }}\n          >\n            <TaskList isMobile={true} />\n            <WelcomeScreen\n              fileSpec={fileSpec}\n              onFileUpload={onFileUpload}\n              onFileUploadError={onFileUploadError}\n              autoScrollRef={autoScrollRef}\n            />\n            <MessagesContainer navigate={navigate} />\n          </div>\n        </ScrollContainer>\n        <div\n          className=\"flex flex-col mx-auto w-full p-4 pt-0\"\n          style={{\n            maxWidth: layoutMaxWidth\n          }}\n        >\n          <ChatFooter\n            fileSpec={fileSpec}\n            onFileUpload={onFileUpload}\n            onFileUploadError={onFileUploadError}\n            autoScrollRef={autoScrollRef}\n          />\n        </div>\n      </ErrorBoundary>\n    </div>\n  );\n};\n\nexport default Chat;\n"
  },
  {
    "path": "frontend/src/components/header/ApiKeys.tsx",
    "content": "import { KeyRound } from 'lucide-react';\nimport { Link } from 'react-router-dom';\n\nimport { useConfig } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\nexport default function ApiKeys() {\n  const { config } = useConfig();\n  const requiredKeys = !!config?.userEnv?.length;\n\n  if (!requiredKeys) return null;\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Link to=\"/env\">\n            <Button\n              id=\"api-keys-button\"\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"text-muted-foreground hover:text-muted-foreground\"\n            >\n              <KeyRound className=\"!size-4\" />\n            </Button>\n          </Link>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            <Translator path=\"navigation.user.menu.apiKeys\" />\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/ChatProfiles.tsx",
    "content": "import { useContext, useEffect, useState } from 'react';\n\nimport {\n  ChainlitContext,\n  useChatInteract,\n  useChatMessages,\n  useChatSession,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { Markdown } from '@/components/Markdown';\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger\n} from '@/components/ui/hover-card';\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@/components/ui/select';\n\nimport { NewChatDialog } from './NewChat';\n\ninterface Props {\n  navigate?: (to: string) => void;\n}\n\nexport default function ChatProfiles({ navigate }: Props) {\n  const apiClient = useContext(ChainlitContext);\n  const { config } = useConfig();\n  const { chatProfile, setChatProfile } = useChatSession();\n  const { firstInteraction } = useChatMessages();\n  const { clear } = useChatInteract();\n  const [newChatProfile, setNewChatProfile] = useState<string | null>(null);\n  const [openDialog, setOpenDialog] = useState(false);\n\n  // Early return check to prevent unnecessary renders and resource waste\n  if (!config?.chatProfiles?.length || config.chatProfiles.length <= 1) {\n    return null;\n  }\n\n  // Handle case when no profile is selected\n  useEffect(() => {\n    if (!chatProfile) {\n      setChatProfile(config.chatProfiles[0].name);\n    }\n  }, [chatProfile, config.chatProfiles, setChatProfile]);\n\n  // Handle case when selected profile becomes invalid\n  useEffect(() => {\n    if (chatProfile) {\n      const profileExists = config.chatProfiles.some(\n        (profile) => profile.name === chatProfile\n      );\n      if (!profileExists) {\n        setChatProfile(config.chatProfiles[0].name);\n      }\n    }\n  }, [chatProfile, config.chatProfiles, setChatProfile]);\n\n  const handleClose = () => {\n    setOpenDialog(false);\n    setNewChatProfile(null);\n    navigate?.('/');\n  };\n\n  const handleConfirm = (profile: string) => {\n    setChatProfile(profile);\n    setNewChatProfile(null);\n    clear();\n    handleClose();\n  };\n\n  const allowHtml = config?.features?.unsafe_allow_html;\n  const latex = config?.features?.latex;\n\n  return (\n    <div className=\"relative\">\n      <Select\n        value={chatProfile || ''}\n        onValueChange={(value) => {\n          setNewChatProfile(value);\n          if (firstInteraction) {\n            setOpenDialog(true);\n          } else {\n            handleConfirm(value);\n          }\n        }}\n      >\n        <SelectTrigger\n          id=\"chat-profiles\"\n          className=\"w-fit border-none bg-transparent text-muted-foreground font-semibold text-lg hover:bg-accent\"\n        >\n          <SelectValue placeholder=\"Select profile\" />\n        </SelectTrigger>\n        <SelectContent>\n          {config.chatProfiles.map((profile) => {\n            const icon = profile.icon?.includes('/public')\n              ? apiClient.buildEndpoint(profile.icon)\n              : profile.icon;\n\n            return (\n              <HoverCard openDelay={0} closeDelay={0} key={profile.name}>\n                <HoverCardTrigger asChild>\n                  <SelectItem\n                    data-test={`select-item:${profile.name}`}\n                    value={profile.name}\n                    className=\"cursor-pointer\"\n                  >\n                    <div className=\"flex items-center gap-2\">\n                      {icon && (\n                        <img\n                          src={icon}\n                          alt={profile.display_name || profile.name}\n                          className=\"w-6 h-6 rounded-md object-cover\"\n                        />\n                      )}\n                      <span>{profile.display_name || profile.name}</span>\n                    </div>\n                  </SelectItem>\n                </HoverCardTrigger>\n                <HoverCardContent\n                  side=\"right\"\n                  id=\"chat-profile-description\"\n                  align=\"start\"\n                  className=\"w-80 overflow-visible\"\n                  sideOffset={10}\n                >\n                  <Markdown\n                    allowHtml={allowHtml}\n                    latex={latex}\n                    renderMarkdown={true}\n                  >\n                    {profile.markdown_description}\n                  </Markdown>\n                </HoverCardContent>\n              </HoverCard>\n            );\n          })}\n        </SelectContent>\n      </Select>\n      <NewChatDialog\n        open={openDialog}\n        handleClose={handleClose}\n        handleConfirm={() => newChatProfile && handleConfirm(newChatProfile)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/NewChat.tsx",
    "content": "import React, { useState } from 'react';\n\nimport { useChatInteract, useConfig } from '@chainlit/react-client';\n\nimport { Translator } from '@/components/i18n';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/dialog';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { EditSquare } from '../icons/EditSquare';\n\ntype NewChatDialogProps = {\n  open: boolean;\n  handleClose: () => void;\n  handleConfirm: () => void;\n};\n\nexport const NewChatDialog = ({\n  open,\n  handleClose,\n  handleConfirm\n}: NewChatDialogProps) => {\n  const handleKeyDown = (event: React.KeyboardEvent) => {\n    event.preventDefault();\n    if (event.key === 'Enter') {\n      handleConfirm();\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent\n        id=\"new-chat-dialog\"\n        className=\"sm:max-w-md\"\n        onKeyDown={handleKeyDown}\n      >\n        <DialogHeader>\n          <DialogTitle>\n            <Translator path=\"navigation.newChat.dialog.title\" />\n          </DialogTitle>\n          <DialogDescription>\n            <Translator path=\"navigation.newChat.dialog.description\" />\n          </DialogDescription>\n        </DialogHeader>\n        <DialogFooter className=\"gap-2 sm:gap-0\">\n          <Button variant=\"outline\" onClick={handleClose}>\n            <Translator path=\"common.actions.cancel\" />\n          </Button>\n          <Button variant=\"default\" onClick={handleConfirm} id=\"confirm\">\n            <Translator path=\"common.actions.confirm\" />\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\ninterface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  navigate?: (to: string) => void;\n  onConfirm?: () => void;\n}\n\nconst NewChatButton = ({ navigate, onConfirm, ...buttonProps }: Props) => {\n  const [open, setOpen] = useState(false);\n  const { clear } = useChatInteract();\n  const { config } = useConfig();\n\n  const handleClickOpen = () => {\n    if (config?.ui?.confirm_new_chat === false) {\n      handleConfirm();\n    } else {\n      setOpen(true);\n    }\n  };\n\n  const handleClose = () => {\n    setOpen(false);\n  };\n\n  const handleConfirm = () => {\n    if (onConfirm) {\n      onConfirm();\n    } else {\n      clear();\n      navigate?.('/');\n    }\n    handleClose();\n  };\n\n  return (\n    <div>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              id=\"new-chat-button\"\n              className=\"text-muted-foreground hover:text-muted-foreground\"\n              onClick={handleClickOpen}\n              {...buttonProps}\n            >\n              <EditSquare className=\"!size-6\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <Translator path=\"navigation.newChat.dialog.tooltip\" />\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <NewChatDialog\n        open={open}\n        handleClose={handleClose}\n        handleConfirm={handleConfirm}\n      />\n    </div>\n  );\n};\n\nexport default NewChatButton;\n"
  },
  {
    "path": "frontend/src/components/header/Readme.tsx",
    "content": "import { useConfig } from '@chainlit/react-client';\n\nimport { Markdown } from '@/components/Markdown';\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger\n} from '@/components/ui/dialog';\nimport { Translator } from 'components/i18n';\n\nimport { useLayoutMaxWidth } from 'hooks/useLayoutMaxWidth';\n\nexport default function ReadmeButton() {\n  const { config } = useConfig();\n  const layoutMaxWidth = useLayoutMaxWidth();\n\n  if (!config?.markdown) {\n    return null;\n  }\n\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button id=\"readme-button\" variant=\"ghost\">\n          <Translator path=\"navigation.header.readme\" />\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"flex flex-col h-screen w-screen max-w-screen max-h-screen border-none !rounded-none overflow-y-auto\">\n        <div\n          className=\"mx-auto flex flex-col flex-grow gap-6\"\n          style={{\n            maxWidth: layoutMaxWidth\n          }}\n        >\n          <DialogHeader>\n            <DialogTitle>\n              <Translator path=\"navigation.header.readme\" />\n            </DialogTitle>\n          </DialogHeader>\n          <Markdown\n            className=\"flex flex-col flex-grow overflow-y-auto\"\n            allowHtml={config?.features?.unsafe_allow_html}\n            latex={config?.features?.latex}\n            renderMarkdown={true}\n          >\n            {config.markdown}\n          </Markdown>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/Share.tsx",
    "content": "import { hasMessage } from '@/lib/utils';\nimport { Share2 } from 'lucide-react';\nimport { useState } from 'react';\nimport { useChatMessages, useConfig } from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport { Translator } from '../i18n';\nimport ShareDialog from '@/components/share/ShareDialog';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nexport default function ShareButton() {\n  const { messages, threadId } = useChatMessages();\n  const [isOpen, setIsOpen] = useState(false);\n  const { config } = useConfig();\n  const dataPersistence = config?.dataPersistence;\n  const threadSharingReady = Boolean((config as any)?.threadSharing);\n\n  // Only show the button if messages, persistence is on, and feature is ready\n  if (!hasMessage(messages) || !dataPersistence || !threadId || !threadSharingReady)\n    return null;\n\n  return (\n    <>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"text-muted-foreground hover:text-muted-foreground\"\n              onClick={() => setIsOpen(true)}\n            >\n              <Share2 className=\"!size-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>\n              <Translator path=\"threadHistory.thread.menu.share\" />\n            </p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n\n      <ShareDialog open={isOpen} onOpenChange={setIsOpen} threadId={threadId} />\n    </>\n  );\n}"
  },
  {
    "path": "frontend/src/components/header/SidebarTrigger.tsx",
    "content": "import { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\nimport { Sidebar } from '../icons/Sidebar';\nimport { useSidebar } from '../ui/sidebar';\n\nexport default function SidebarTrigger() {\n  const { setOpen, open, openMobile, setOpenMobile, isMobile } = useSidebar();\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            id=\"sidebar-trigger-button\"\n            onClick={() =>\n              isMobile ? setOpenMobile(!openMobile) : setOpen(!open)\n            }\n            size=\"icon\"\n            variant=\"ghost\"\n            className=\"text-muted-foreground hover:text-muted-foreground\"\n          >\n            <Sidebar className=\"!size-6\" />\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            {open ? (\n              <Translator path=\"threadHistory.sidebar.actions.close\" />\n            ) : (\n              <Translator path=\"threadHistory.sidebar.actions.open\" />\n            )}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/ThemeToggle.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Moon, Sun } from 'lucide-react';\n\nimport { useTheme } from '@/components/ThemeProvider';\nimport { Button } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger\n} from '@/components/ui/dropdown-menu';\nimport { Translator } from 'components/i18n';\n\ninterface Props {\n  className?: string;\n}\n\nexport function ThemeToggle({ className }: Props) {\n  const { setTheme } = useTheme();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          id=\"theme-toggle\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className={cn(\n            'text-muted-foreground hover:text-muted-foreground',\n            className\n          )}\n        >\n          <Sun className=\"!size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n          <Moon className=\"absolute !size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n          <span className=\"sr-only\">Toggle theme</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem onClick={() => setTheme('light')}>\n          <Translator path=\"navigation.header.theme.light\" />\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme('dark')}>\n          <Translator path=\"navigation.header.theme.dark\" />\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme('system')}>\n          <Translator path=\"navigation.header.theme.system\" />\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/UserNav.tsx",
    "content": "import capitalize from 'lodash/capitalize';\nimport { LogOut } from 'lucide-react';\n\nimport { useAuth } from '@chainlit/react-client';\n\nimport { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';\nimport { Button } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger\n} from '@/components/ui/dropdown-menu';\nimport { Translator } from 'components/i18n';\n\nexport default function UserNav() {\n  const { user, logout } = useAuth();\n\n  if (!user) return null;\n  const displayName = user?.display_name || user?.identifier;\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          id=\"user-nav-button\"\n          variant=\"ghost\"\n          className=\"relative h-8 w-8 rounded-full\"\n        >\n          <Avatar className=\"h-8 w-8\">\n            <AvatarImage src={user?.metadata.image} alt=\"user image\" />\n            <AvatarFallback className=\"bg-primary text-primary-foreground font-semibold\">\n              {capitalize(displayName[0])}\n            </AvatarFallback>\n          </Avatar>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent className=\"w-26\" align=\"end\" forceMount>\n        <DropdownMenuLabel className=\"font-normal\">\n          <div className=\"flex flex-col space-y-1\">\n            <p className=\"text-sm font-medium leading-none\">{displayName}</p>\n          </div>\n        </DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        <DropdownMenuItem onClick={() => logout(true)}>\n          <Translator path=\"navigation.user.menu.logout\" />\n          <LogOut className=\"ml-auto\" />\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/header/index.tsx",
    "content": "import { memo } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useSetRecoilState } from 'recoil';\n\nimport { useAudio, useAuth, useChatData, useConfig } from '@chainlit/react-client';\n\nimport AudioPresence from '@/components/AudioPresence';\nimport ButtonLink from '@/components/ButtonLink';\nimport { Settings } from '@/components/icons/Settings';\nimport { Button } from '@/components/ui/button';\nimport { useSidebar } from '@/components/ui/sidebar';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\nimport { Translator } from 'components/i18n';\n\nimport { chatSettingsSidebarOpenState } from '@/state/project';\n\nimport ApiKeys from './ApiKeys';\nimport ChatProfiles from './ChatProfiles';\nimport NewChatButton from './NewChat';\nimport ReadmeButton from './Readme';\nimport ShareButton from './Share';\nimport SidebarTrigger from './SidebarTrigger';\nimport { ThemeToggle } from './ThemeToggle';\nimport UserNav from './UserNav';\n\nconst Header = memo(() => {\n  const { audioConnection } = useAudio();\n  const navigate = useNavigate();\n  const { data } = useAuth();\n  const { config } = useConfig();\n  const { chatSettingsInputs } = useChatData();\n  const { open, openMobile, isMobile } = useSidebar();\n  const setChatSettingsSidebarOpen = useSetRecoilState(\n    chatSettingsSidebarOpenState\n  );\n\n  const sidebarOpen = isMobile ? openMobile : open;\n\n  const historyEnabled = data?.requireLogin && config?.dataPersistence;\n  const sidebarHidden = config?.ui?.default_sidebar_state === 'hidden';\n\n  const links = config?.ui?.header_links || [];\n\n  const showSettingsInHeader =\n    config?.ui?.chat_settings_location === 'sidebar' &&\n    chatSettingsInputs.length > 0;\n\n  return (\n    <div\n      className=\"p-3 flex h-[60px] items-center justify-between gap-2 relative\"\n      id=\"header\"\n    >\n      <div className=\"flex items-center\">\n        {historyEnabled && !sidebarHidden ? !sidebarOpen ? <SidebarTrigger /> : null : null}\n        {historyEnabled && !sidebarHidden ? (\n          !sidebarOpen ? (\n            <NewChatButton navigate={navigate} />\n          ) : null\n        ) : (\n          <NewChatButton navigate={navigate} />\n        )}\n\n        <ChatProfiles navigate={navigate} />\n      </div>\n\n      <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2\">\n        {audioConnection === 'on' ? (\n          <AudioPresence\n            type=\"server\"\n            height={35}\n            width={70}\n            barCount={4}\n            barSpacing={2}\n          />\n        ) : null}\n      </div>\n\n      <div />\n      <div className=\"flex items-center gap-1\">\n        <ShareButton />\n        <ReadmeButton />\n        <ApiKeys />\n        {links &&\n          links.map((link, index) => (\n            <ButtonLink\n              key={`${link.name}-${link.url}-${index}`}\n              name={link.name}\n              displayName={link.display_name}\n              iconUrl={link.icon_url}\n              url={link.url}\n              target={link.target}\n            />\n          ))}\n        {showSettingsInHeader && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                id=\"chat-settings-header-button\"\n                onClick={() => setChatSettingsSidebarOpen(true)}\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"text-muted-foreground hover:text-muted-foreground\"\n              >\n                <Settings className=\"!size-5\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <Translator path=\"chat.settings.title\" />\n            </TooltipContent>\n          </Tooltip>\n        )}\n        <ThemeToggle />\n        <UserNav />\n      </div>\n    </div>\n  );\n});\n\nexport { Header };\n"
  },
  {
    "path": "frontend/src/components/i18n/Translator.tsx",
    "content": "import { TOptions } from 'i18next';\nimport { $Dictionary } from 'i18next/typescript/helpers';\nimport { useTranslation as usei18nextTranslation } from 'react-i18next';\n\nimport { Skeleton } from '@/components/ui/skeleton';\n\ntype options = TOptions<$Dictionary>;\n\ntype TranslatorProps = {\n  path: string | string[];\n  suffix?: string;\n  options?: options;\n};\n\nconst Translator = ({ path, options, suffix }: TranslatorProps) => {\n  const { t, i18n } = usei18nextTranslation();\n\n  if (!i18n.exists(path, options)) {\n    return <Skeleton className=\"h-4 w-10\" />;\n  }\n\n  return (\n    <span>\n      {t(path, options)}\n      {suffix}\n    </span>\n  );\n};\n\nexport const useTranslation = () => {\n  const { t, ready, i18n } = usei18nextTranslation();\n\n  return {\n    t: (path: string | string[], options?: options) => {\n      if (!i18n.exists(path, options)) {\n        return '...';\n      }\n\n      return t(path, options);\n    },\n    ready,\n    i18n\n  };\n};\n\nexport default Translator;\n"
  },
  {
    "path": "frontend/src/components/i18n/index.ts",
    "content": "export { default as Translator } from './Translator';\n"
  },
  {
    "path": "frontend/src/components/icons/Auth0.tsx",
    "content": "export const Auth0 = () => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 256 287\"\n      className=\"fill-text-primary\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      preserveAspectRatio=\"xMinYMin meet\"\n    >\n      <path d=\"M203.24 231.531l-28.73-88.434 75.208-54.64h-92.966L128.019.025l-.009-.024h92.98l28.74 88.446.002-.002.024-.013c16.69 51.31-.5 109.67-46.516 143.098zm-150.45 0l-.023.017 75.228 54.655 75.245-54.67-75.221-54.656-75.228 54.654zM6.295 88.434c-17.57 54.088 2.825 111.4 46.481 143.108l.007-.028 28.735-88.429-75.192-54.63h92.944L128.004.024 128.01 0H35.025L6.294 88.434z\" />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Cognito.tsx",
    "content": "export const Cognito = () => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 256 299\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      preserveAspectRatio=\"xMidYMid\"\n    >\n      <path\n        d=\"M208.752 58.061l25.771-6.636.192.283.651 155.607-.843.846-5.31.227-20.159-3.138-.302-.794V58.061M59.705 218.971l.095.007 68.027 19.767.173.133.296.236-.096 59.232-.2.252-68.295-33.178v-46.449\"\n        fill=\"#7A3E65\"\n      />\n      <path\n        d=\"M208.752 204.456l-80.64 19.312-40.488-9.773-27.919 4.976L128 238.878l105.405-28.537 1.118-2.18-25.771-3.705\"\n        fill=\"#CFB2C1\"\n      />\n      <path\n        d=\"M196.295 79.626l-.657-.749-66.904-19.44-.734.283-.672-.343L22.052 89.734l-.575.703.845.463 24.075 3.53.851-.289 80.64-19.311 40.488 9.773 27.919-4.977\"\n        fill=\"#512843\"\n      />\n      <path\n        d=\"M47.248 240.537l-25.771 6.221-.045-.149-1.015-155.026 1.06-1.146 25.771 3.704v146.396\"\n        fill=\"#C17B9E\"\n      />\n      <path\n        d=\"M82.04 180.403l45.96 5.391.345-.515.187-71.887-.532-.589-45.96 5.392v62.208\"\n        fill=\"#7A3E65\"\n      />\n      <path\n        d=\"M173.96 180.403L128 185.794v-72.991l45.96 5.392v62.208M196.295 79.626L128 59.72V0l68.295 33.177v46.449\"\n        fill=\"#C17B9E\"\n      />\n      <path\n        d=\"M128 0L0 61.793v175.011l21.477 9.954V90.437L128 59.72V0\"\n        fill=\"#7A3E65\"\n      />\n      <path\n        d=\"M234.523 51.425v156.736L128 238.878v59.72l128-61.794V61.793l-21.477-10.368\"\n        fill=\"#C17B9E\"\n      />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Descope.tsx",
    "content": "export const Descope = () => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      version=\"1.1\"\n      viewBox=\"0 0 196 216\"\n      xmlSpace=\"preserve\"\n    >\n      <path\n        fill=\"#0198B8\"\n        d=\"M66.841 183.009c.049 4.355.097 8.71-.27 13.485-11.818-6.767-23.167-14.04-34.66-21.077-3.023-1.85-6.494-2.968-9.759-4.421-1.994-5.399-3.99-10.797-5.564-16.59 2.378.369 4.526.837 6.264 1.947 12.42 7.933 24.72 16.054 37.157 23.961 2.028 1.29 4.543 1.816 6.832 2.695z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#0BE5DA\"\n        d=\"M56.298 102c.85-.912 1.624-2.505 2.565-2.611 3.038-.343 6.137-.139 9.679.29 1.212 1.44 1.753 2.758 2.736 3.396 17.255 11.216 34.541 22.386 51.898 33.444 1.855 1.182 4.195 1.604 6.31 2.38-.19 1.28.077 3.15-.662 3.723-1.792 1.39-4.052 2.174-6.483 2.884-1.164-1.446-1.731-2.93-2.809-3.643-7.175-4.742-14.43-9.367-21.72-13.934-4.877-3.057-9.855-5.954-14.788-8.923-1.091-.413-2.182-.827-3.71-1.653-7.963-5.393-15.49-10.373-23.016-15.353z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#13E5D1\"\n        d=\"M129.377 138.545c-2.006-.422-4.346-.844-6.201-2.026-17.357-11.058-34.643-22.228-51.898-33.444-.983-.638-1.524-1.957-2.285-3.3 3.358-.406 6.73-.473 10.563-.1 1.243 1.615 1.78 3.14 2.856 3.903 3.597 2.547 7.509 4.656 11.063 7.256 2.387 1.748 4.374 4.043 6.254 6.143-.431.113-.606.145-.713.247-.096.092-.118.261-.172.397l5.186 1.336c1.656.136 3.312.279 4.968.406 7.268.557 12.628 3.671 15.482 10.766 1.153 2.866 3.167 5.386 4.897 8.416z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#07E4E3\"\n        d=\"M82.727 119.17a257.804 257.804 0 0115.086 8.759c7.29 4.567 14.544 9.192 21.719 13.934 1.078.713 1.645 2.197 2.45 3.691-2.826 1.962-5.652 3.557-9.194 5.557-2.288-4.191-4.438-7.98-6.446-11.841-3.98-7.655-7.928-7.877-13.248-.74-5.877-3.234-11.26-6.38-16.642-9.524 1.993-3.225 3.986-6.449 6.275-9.837z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#57DB45\"\n        d=\"M180.423 56.014c.762 2.732 1.524 5.464 1.853 8.58-1.646-.019-3-.2-4.05-.863-9.341-5.915-18.569-12.013-27.954-17.857-6.12-3.811-12.448-7.29-18.682-10.92-.078-2.364-.156-4.729.227-7.511 11.469 6.52 22.417 13.556 33.511 20.353 4.879 2.989 10.054 5.493 15.095 8.218z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#06A3BE\"\n        d=\"M66.914 182.573c-2.362-.443-4.877-.97-6.905-2.259-12.437-7.907-24.737-16.028-37.157-23.96-1.738-1.11-3.886-1.58-6.217-2.344-.464-.472-.555-.944-.662-2.2.014-2.802.045-4.819.445-6.821 4.9 2.647 9.35 5.428 13.983 7.865 3.738 1.966 7.696 3.516 11.557 5.25 5.502 9.625 13.99 15.097 25.029 17.64 0 2.146 0 4.27-.073 6.83z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#088FB2\"\n        d=\"M22.181 171.339c3.236 1.11 6.707 2.227 9.73 4.078 11.493 7.036 22.842 14.31 34.591 21.536.346 1.483.346 2.924.346 4.552-19.021-2.271-32.731-12.099-42.868-27.5-.534-.811-1.177-1.55-1.799-2.666z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#5CDB3C\"\n        d=\"M180.408 55.677c-5.026-2.388-10.201-4.892-15.08-7.881-11.094-6.797-22.042-13.833-33.376-20.778-.418-2.055-.51-4.103-.145-6.568 1.673.084 3 .414 4.09 1.112 10.474 6.71 20.862 13.554 31.389 20.18 2.318 1.459 5.084 2.206 7.644 3.281 1.821 3.44 3.642 6.878 5.478 10.654z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#59DD55\"\n        d=\"M131.458 35.376c6.366 3.207 12.693 6.687 18.814 10.498 9.385 5.844 18.613 11.942 27.954 17.857 1.05.664 2.404.844 3.974 1.244.501 2.046.646 4.098.38 6.545-7.279-3.892-14.065-8.316-21.058-12.385-2.192-1.275-4.942-1.59-7.436-2.346-5.384-3.857-10.328-8.777-16.286-11.25-5.178-2.148-7.703-4.195-6.342-10.163z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#01E3E3\"\n        d=\"M15.447 90.994c-.065-4.355-.131-8.71.255-13.521 2.615.575 4.824 1.524 6.932 2.657 5.425 2.916 10.805 5.913 16.203 8.878.046 4.358.092 8.716-.295 13.527-3.835-1.435-7.207-3.382-10.647-5.197-4.118-2.173-8.296-4.234-12.448-6.344z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#22E3B4\"\n        d=\"M100.982 92.49c-5.371.802-10.758 1.513-16.107 2.443-2.781.484-4.543-.264-5.988-2.802-3.166-5.56-6.618-10.957-10.33-17.038 2.094-1.064 3.92-1.994 6.137-2.627 1.35 1.37 2.138 2.71 3.302 3.476 6.054 3.99 12.27 7.737 18.282 11.79 1.813 1.222 3.15 3.153 4.704 4.759z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#66DB3A\"\n        d=\"M174.883 44.68c-2.513-.732-5.279-1.48-7.597-2.938-10.527-6.626-20.915-13.47-31.39-20.18-1.09-.698-2.416-1.028-3.964-1.533-1.176-3.157-.493-4.067 3.096-3.379 17.434 3.343 30.305 13.045 39.855 28.03z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#07E4E3\"\n        d=\"M173.516 175.205c-2.971 3.499-5.943 6.998-9.27 10.29-4.452-3.19-8.447-6.328-12.682-9.1-2.676-1.75-5.71-2.954-8.585-4.403 3.165-2.552 6.33-5.105 10.158-7.814 2.674.924 4.708 1.962 6.688 3.095 4.578 2.62 9.13 5.285 13.691 7.932z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#19E3BC\"\n        d=\"M128.157 117.563c-5.08.066-10.162.133-15.708-.24-1.486-1.621-2.32-3.067-3.56-3.937-4.746-3.333-9.7-6.372-14.403-9.762-1.448-1.044-2.341-2.857-3.488-4.318 5.015-.057 10.03-.114 15.524.242 1.13 1.272 1.615 2.373 2.46 2.95 4.987 3.41 10.154 6.566 15.047 10.101 1.69 1.22 2.77 3.285 4.128 4.964z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#26E2A8\"\n        d=\"M101.374 92.561c-1.947-1.676-3.283-3.607-5.096-4.83-6.011-4.052-12.228-7.798-18.282-11.789-1.164-.767-1.951-2.106-2.947-3.525 2.391-1.668 4.817-2.992 7.748-4.059 2.898 1.398 5.29 2.537 7.683 3.677 1.158 2.159 2.405 4.276 3.453 6.487 1.481 3.128 3.688 4.935 8.075 4.528 3.838 2.53 6.91 5.09 9.981 7.649-3.408.644-6.815 1.289-10.615 1.862z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#52DD5A\"\n        d=\"M154.217 57.132c2.363.412 5.113.728 7.305 2.003 6.993 4.069 13.78 8.493 20.991 12.823.386 3.074.426 6.106.057 9.544-8.285-4.232-16.162-8.87-24.038-13.508a10797.74 10797.74 0 01-4.315-10.862z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00CFD8\"\n        d=\"M16.05 144.975c-.032 2.016-.063 4.033-.11 6.474-.266-1.494-.726-3.41-.731-5.328-.036-15.064.016-30.13.484-45.645 2.058.335 3.731 1.02 5.277 1.925 5.976 3.495 11.912 7.06 17.863 10.6.047 2.703.095 5.406-.273 8.51-7.515-3.888-14.615-8.178-22.472-12.926 0 3.858 0 6.64-.026 9.87-.011 3.147.004 5.844-.011 8.991-.014 3.152.002 5.853-.008 9.004-.015 3.14-.004 5.833.006 8.525z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#22E2AA\"\n        d=\"M112.026 99.266c3.355-.08 6.71-.16 10.58.153 7.661 4.795 14.808 9.196 21.955 13.596v4.757c-3.706 0-7.16 0-11.087-.418-1.152-1.264-1.678-2.334-2.536-2.918-5.006-3.407-10.197-6.553-15.088-10.114-1.635-1.19-2.57-3.343-3.824-5.056z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#0BE5DA\"\n        d=\"M38.909 88.559c-5.47-2.516-10.85-5.513-16.275-8.429-2.108-1.133-4.317-2.082-6.836-3.111-.392-3.025-.428-6.053-.01-9.536 2.605.583 4.8 1.543 6.903 2.674 5.414 2.913 10.786 5.902 16.176 8.86.038 3.03.076 6.062.042 9.542z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#04D9DF\"\n        d=\"M142.637 172.105c3.216 1.336 6.251 2.54 8.927 4.29 4.235 2.772 8.23 5.91 12.376 9.23-1.94 2.225-3.928 4.112-6.416 5.68-2.805-2.126-5.087-3.967-7.423-5.736-4.688-3.55-9.406-7.062-14.111-10.59 2.101-.92 4.203-1.841 6.647-2.874z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#07E4E3\"\n        d=\"M56.216 102.46c7.609 4.52 15.135 9.5 22.722 14.795-7.345.314-14.751.314-22.804.314 0-4.869 0-9.758.082-15.108z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00D9DE\"\n        d=\"M38.904 112.552c-6.022-3.09-11.958-6.656-17.934-10.151-1.546-.904-3.22-1.59-5.178-2.382-.41-2.718-.475-5.43-.443-8.582 4.25 1.667 8.428 3.728 12.546 5.9 3.44 1.816 6.812 3.763 10.576 5.658.41 3.04.456 6.074.433 9.557z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#02ABC3\"\n        d=\"M16.418 144.989c-.38-2.706-.39-5.398-.012-8.534 2.24.512 4.135 1.395 5.936 2.439 5.769 3.34 11.503 6.74 17.25 10.117.75 2.772 1.498 5.544 2.306 8.705-3.801-1.346-7.759-2.896-11.497-4.862-4.633-2.437-9.084-5.218-13.983-7.865z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#14E4C7\"\n        d=\"M90.544 99.234c1.6 1.533 2.494 3.346 3.942 4.39 4.703 3.39 9.657 6.43 14.404 9.762 1.239.87 2.073 2.316 3.106 3.838-2.395.419-4.804.497-7.903.314-1.82-.378-2.949-.495-4.078-.611-2.166-2.05-4.153-4.345-6.54-6.093-3.554-2.6-7.466-4.71-11.063-7.256-1.077-.763-1.613-2.288-2.409-3.804 3.355-.43 6.722-.521 10.541-.54z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00BDCD\"\n        d=\"M16.08 126.997c-.014-2.698-.029-5.395.343-8.539 2.267.537 4.164 1.488 6.024 2.508 5.47 2.998 10.925 6.023 16.386 9.038.048 3.032.096 6.064-.268 9.501-7.769-3.9-15.127-8.204-22.484-12.508z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#14E4C7\"\n        d=\"M39.985 66.998c-.326 2.73-.653 5.46-1.395 8.557-1.717-.33-3.022-1.016-4.317-1.721-6.023-3.282-12.043-6.569-18.064-9.854.57-2.436 1.14-4.873 2.25-7.643a29.74 29.74 0 017.06 2.707c4.853 2.594 9.648 5.296 14.466 7.954z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#02B4C8\"\n        d=\"M16.05 127.446c7.388 3.855 14.746 8.16 22.445 12.509.642 2.778.942 5.511 1.17 8.65-5.82-2.971-11.554-6.371-17.323-9.711-1.801-1.044-3.695-1.927-5.91-2.888-.379-2.707-.395-5.408-.381-8.56z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00C6D3\"\n        d=\"M38.904 129.564c-5.532-2.575-10.987-5.6-16.457-8.598a74.807 74.807 0 00-5.998-2.956c-.36-2.785-.36-5.567-.36-9.425 7.856 4.748 14.956 9.038 22.4 13.372.391 2.418.439 4.792.415 7.607z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#19E3BC\"\n        d=\"M159.333 145.996c0-2.371 0-4.742.456-7.537 2.769.814 5.081 2.052 7.395 3.286 5.17 2.758 10.34 5.513 15.511 8.27-.218 2.093-.435 4.186-1.201 6.632-2.487-.413-4.517-1.009-6.349-1.978-5.313-2.81-10.547-5.771-15.812-8.673z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#36E087\"\n        d=\"M43.012 25.04c1.452-.952 2.904-1.903 4.846-2.6 6.652 4.024 12.816 7.791 18.98 11.558-.346 3.21-.692 6.42-1.446 9.266-1.63-1.61-2.685-3.108-4.105-4.06-4.952-3.319-10.12-6.323-15.008-9.73-1.426-.994-2.196-2.93-3.267-4.434z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#22E2AA\"\n        d=\"M182.848 137.005c.043 2.042.085 4.084-.298 6.554-5.814-2.777-11.152-6.073-16.617-9.141-1.954-1.097-4.217-1.646-6.338-2.445-.083-2.362-.166-4.724.204-7.511 1.925.273 3.46.865 4.857 1.688 6.086 3.582 12.132 7.23 18.192 10.855z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#10D2DA\"\n        d=\"M135.591 174.997c5.104 3.51 9.822 7.021 14.51 10.572 2.336 1.769 4.618 3.61 7.07 5.788-1.752 1.637-3.65 2.903-5.878 3.865-6.792-4.604-13.253-8.903-19.714-13.201-.304-3.114-1.881-6.883 4.012-7.024z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#2EE29A\"\n        d=\"M182.85 123.001c.044 2.044.088 4.087-.298 6.559-2.865-.817-5.323-2.023-7.734-3.316-5.092-2.732-10.158-5.51-15.235-8.268-.083-2.367-.165-4.733.205-7.523 1.196-.108 1.986.128 2.677.533 6.804 3.99 13.593 8.006 20.386 12.015z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#34E192\"\n        d=\"M42.672 25.09c1.41 1.454 2.181 3.39 3.607 4.384 4.889 3.407 10.056 6.411 15.008 9.73 1.42.952 2.475 2.45 3.713 4.031a22.66 22.66 0 01-6.684 2.299c-1.223-1.3-1.863-2.572-2.894-3.245-6.364-4.15-12.816-8.163-19.239-12.223 2.05-1.641 4.1-3.283 6.489-4.975z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#2EE29A\"\n        d=\"M35.89 30.195a473.368 473.368 0 0119.532 12.094c1.031.673 1.67 1.946 2.525 3.284-1.092.995-2.22 1.647-4.042 2.354a312.328 312.328 0 01-19.87-11.69c-.703-.449-.679-2.038-.991-3.097.85-.94 1.7-1.878 2.845-2.945z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#3CDF74\"\n        d=\"M182.838 95.002c.044 2.044.087 4.087-.3 6.558-3.87-1.491-7.28-3.461-10.755-5.314-4.021-2.144-8.098-4.185-12.151-6.27-.091-2.039-.182-4.078.203-6.542a33.016 33.016 0 018.01 3.094c5.042 2.744 10 5.64 14.993 8.474z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#38DF7C\"\n        d=\"M159.495 90.398c4.19 1.663 8.267 3.704 12.288 5.848 3.474 1.853 6.886 3.823 10.687 5.747.41 2.048.456 4.09.076 6.562-3.872-1.488-7.29-3.459-10.77-5.313-4.032-2.148-8.117-4.193-12.179-6.283-.08-2.046-.159-4.092-.102-6.56z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#04D9DF\"\n        d=\"M76.254 129.3c5.58 2.85 10.963 5.996 16.54 9.41-.633 2.14-1.462 4.01-2.936 6.076-6.049-3.127-11.451-6.45-16.854-9.775a537.765 537.765 0 013.25-5.712z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#44DE6C\"\n        d=\"M182.906 94.568c-5.06-2.4-10.019-5.296-15.06-8.04-2.43-1.322-5.018-2.355-7.879-3.514-.47-2.047-.597-4.1-.263-6.583 3.218.886 6.032 2.098 8.72 3.542 4.845 2.602 9.615 5.343 14.416 8.026.045 2.045.09 4.09.066 6.57z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#34E192\"\n        d=\"M182.917 122.567c-6.86-3.575-13.648-7.592-20.452-11.581-.691-.405-1.48-.641-2.552-.96-.413-1.73-.498-3.454-.111-5.598 3.012.683 5.653 1.602 8.073 2.927 5.055 2.769 9.993 5.751 14.98 8.646.043 2.044.086 4.088.062 6.566z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#0BE5DA\"\n        d=\"M173.79 175.063c-4.836-2.505-9.387-5.17-13.965-7.79a75.774 75.774 0 00-6.388-3.251 24.082 24.082 0 013.526-6.486c6.92 4.175 13.374 8.288 19.829 12.401-.91 1.662-1.818 3.323-3.001 5.126z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00C6D3\"\n        d=\"M131.465 182.46c6.575 3.86 13.036 8.158 19.505 12.8-2.413 1.401-4.832 2.458-7.663 3.16-4.167-2.713-7.922-5.073-11.677-7.433-.093-2.696-.186-5.393-.165-8.528z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#14E4C7\"\n        d=\"M159.236 146.383c5.362 2.515 10.596 5.475 15.909 8.286 1.832.97 3.862 1.565 6.17 2.331-.242 2.213-.853 4.426-2.005 6.782-4.846-2.523-9.07-5.332-13.486-7.796-2.34-1.306-5.014-2.011-7.537-2.988.284-2.076.568-4.152.949-6.615z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#48DD63\"\n        d=\"M182.907 87.574c-4.868-2.258-9.638-5-14.482-7.601-2.69-1.444-5.503-2.656-8.62-3.972-.713-2.409-1.067-4.818-1.347-7.617 7.95 4.248 15.827 8.886 24.044 13.568.385 1.761.428 3.479.405 5.622z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#3BE189\"\n        d=\"M182.921 115.566c-5.053-2.46-9.99-5.442-15.046-8.21-2.42-1.326-5.061-2.245-7.952-3.343-.431-2.064-.513-4.132-.46-6.628 4.196 1.664 8.281 3.71 12.312 5.857 3.48 1.854 6.899 3.825 10.708 5.747.41 2.05.457 4.096.438 6.577z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#22E2AA\"\n        d=\"M47.885 52.757c-.817.965-1.634 1.931-3.08 3.02-7.014-4.162-13.399-8.447-19.784-12.732.813-1.245 1.626-2.49 3.083-3.849 4.956 2.48 9.387 4.9 13.535 7.734 2.324 1.588 4.18 3.862 6.246 5.827z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#19E3BC\"\n        d=\"M40.25 66.76c-5.083-2.42-9.878-5.122-14.731-7.716-2.112-1.129-4.343-2.034-6.888-3.041.25-1.793.87-3.585 2.047-5.683 2.473.551 4.455 1.287 6.292 2.287 5.016 2.732 9.972 5.576 14.952 8.376-.47 1.846-.939 3.692-1.673 5.776z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#22E3B4\"\n        d=\"M42.186 60.792c-5.244-2.609-10.2-5.453-15.216-8.185-1.837-1-3.819-1.736-6.082-2.585.951-2.219 2.25-4.445 3.84-6.824 6.678 4.132 13.063 8.417 19.794 12.73-.46 1.576-1.266 3.124-2.336 4.864zM159.464 132.392c2.252.38 4.515.929 6.469 2.026 5.465 3.068 10.803 6.364 16.552 9.575.379 1.73.397 3.455.313 5.601-5.273-2.336-10.444-5.091-15.614-7.849-2.314-1.234-4.626-2.472-7.27-3.712-.414-1.743-.497-3.483-.45-5.641z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#2CE2A4\"\n        d=\"M159.455 118.395c5.205 2.34 10.271 5.117 15.363 7.849a115.926 115.926 0 007.67 3.75c.408 2.051.453 4.097.429 6.578-6.13-3.191-12.175-6.84-18.26-10.422-1.399-.823-2.933-1.415-4.735-2.115-.418-1.742-.506-3.481-.467-5.64z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#34E192\"\n        d=\"M107.005 77c.83-1.862 1.66-3.724 3.114-5.754 6.028 3.442 11.431 7.05 16.835 10.66-.85 1.562-1.7 3.124-3.171 4.838-6.008-3.147-11.393-6.445-16.778-9.743z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#13E5D1\"\n        d=\"M158.124 153.32c2.686.655 5.36 1.36 7.7 2.666 4.416 2.464 8.64 5.273 13.304 8.085-.331 1.922-1.024 3.697-2.027 5.67-6.764-3.917-13.219-8.03-19.872-12.406.112-1.406.422-2.55.895-4.015z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#10D2DA\"\n        d=\"M72.707 135.176c5.7 3.16 11.102 6.483 16.876 9.808-.464 1.924-1.298 3.846-2.388 6.357l-18.676-9.997c1.469-2.266 2.68-4.134 4.188-6.168z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#40E28D\"\n        d=\"M127.235 81.727c-5.685-3.43-11.088-7.04-16.845-10.681.36-1.67 1.074-3.307 2.092-5.642l18.892 9.704c-1.531 2.556-2.695 4.498-4.14 6.619z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#32E29F\"\n        d=\"M106.734 77.187c5.656 3.112 11.04 6.41 16.77 9.736-2.041 4.98-6.953 2.77-11.099 3.845-3.487-2.629-6.559-5.188-9.995-7.75 1.108-1.883 2.581-3.764 4.324-5.83z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#26E2A8\"\n        d=\"M144.662 112.586c-7.248-3.971-14.395-8.372-21.603-13.101 3.302-.374 6.663-.42 10.541-.073 4.004 2.466 7.492 4.54 10.98 6.613.06 2.044.121 4.088.082 6.561z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#2CE2A4\"\n        d=\"M48.197 52.661c-2.379-1.87-4.234-4.143-6.558-5.731-4.148-2.835-8.579-5.255-13.244-7.882 1.117-1.9 2.587-3.768 4.353-5.772.608.923.584 2.512 1.287 2.96 6.318 4.029 12.765 7.857 19.54 11.745-1.446 1.528-3.256 3.057-5.378 4.68z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#38DF7C\"\n        d=\"M66.904 33.565c-6.23-3.334-12.394-7.101-18.71-11.163a27.463 27.463 0 017.47-3.006c3.998 2.755 7.584 5.18 11.17 7.604.045 2.043.09 4.087.07 6.565z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#00BDCD\"\n        d=\"M131.497 191.427c3.888 1.92 7.643 4.28 11.448 6.969-3.55 1.412-7.15 2.496-11.582 3.83 0-3.906 0-7.133.134-10.8z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#32E6BE\"\n        d=\"M111.61 99.179c1.67 1.8 2.605 3.952 4.24 5.143 4.89 3.56 10.082 6.707 15.088 10.114.858.584 1.384 1.654 2.086 2.837-1.387.386-2.797.437-4.537.388-1.688-1.777-2.768-3.841-4.458-5.062-4.893-3.535-10.06-6.69-15.047-10.1-.845-.578-1.33-1.68-2-2.884 1.392-.404 2.802-.464 4.628-.436z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#3CDF74\"\n        d=\"M66.901 26.546c-3.653-1.97-7.239-4.395-10.86-7.165 3.35-1.367 6.735-2.387 10.928-3.65 0 3.915 0 7.138-.068 10.815z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#23E0D5\"\n        d=\"M16.109 64.287c6.121 2.978 12.14 6.265 18.164 9.547 1.295.705 2.6 1.392 4.249 2.12.395.793.442 1.553.417 2.688-5.462-2.583-10.834-5.572-16.248-8.485-2.103-1.131-4.298-2.09-6.798-3.113-.193-.806-.039-1.628.216-2.757z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#32E29F\"\n        d=\"M144.68 105.59c-3.588-1.639-7.076-3.712-10.626-6.115 3.355-.33 6.772-.33 10.726-.33 0 2.18 0 4.094-.1 6.444zM90.45 71.705c-2.363-.81-4.755-1.95-7.3-3.396.974-1.133 2.102-1.96 3.466-2.96 1.41 2.235 2.607 4.13 3.834 6.356z\"\n        opacity=\"1\"\n      ></path>\n      <path\n        fill=\"#32E6BE\"\n        d=\"M99.73 116.977c1.414.066 2.543.183 3.91.537.249.441.26.644.33 1.145-1.669-.147-3.397-.593-5.126-1.038.054-.136.076-.305.172-.397.107-.102.282-.134.713-.247z\"\n        opacity=\"1\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/EditSquare.tsx",
    "content": "export const EditSquare = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <path\n        d=\"M15.6729 3.91287C16.8918 2.69392 18.8682 2.69392 20.0871 3.91287C21.3061 5.13182 21.3061 7.10813 20.0871 8.32708L14.1499 14.2643C13.3849 15.0293 12.3925 15.5255 11.3215 15.6785L9.14142 15.9899C8.82983 16.0344 8.51546 15.9297 8.29289 15.7071C8.07033 15.4845 7.96554 15.1701 8.01005 14.8586L8.32149 12.6785C8.47449 11.6075 8.97072 10.615 9.7357 9.85006L15.6729 3.91287ZM18.6729 5.32708C18.235 4.88918 17.525 4.88918 17.0871 5.32708L11.1499 11.2643C10.6909 11.7233 10.3932 12.3187 10.3014 12.9613L10.1785 13.8215L11.0386 13.6986C11.6812 13.6068 12.2767 13.3091 12.7357 12.8501L18.6729 6.91287C19.1108 6.47497 19.1108 5.76499 18.6729 5.32708ZM11 3.99929C11.0004 4.55157 10.5531 4.99963 10.0008 5.00007C9.00227 5.00084 8.29769 5.00827 7.74651 5.06064C7.20685 5.11191 6.88488 5.20117 6.63803 5.32695C6.07354 5.61457 5.6146 6.07351 5.32698 6.63799C5.19279 6.90135 5.10062 7.24904 5.05118 7.8542C5.00078 8.47105 5 9.26336 5 10.4V13.6C5 14.7366 5.00078 15.5289 5.05118 16.1457C5.10062 16.7509 5.19279 17.0986 5.32698 17.3619C5.6146 17.9264 6.07354 18.3854 6.63803 18.673C6.90138 18.8072 7.24907 18.8993 7.85424 18.9488C8.47108 18.9992 9.26339 19 10.4 19H13.6C14.7366 19 15.5289 18.9992 16.1458 18.9488C16.7509 18.8993 17.0986 18.8072 17.362 18.673C17.9265 18.3854 18.3854 17.9264 18.673 17.3619C18.7988 17.1151 18.8881 16.7931 18.9393 16.2535C18.9917 15.7023 18.9991 14.9977 18.9999 13.9992C19.0003 13.4469 19.4484 12.9995 20.0007 13C20.553 13.0004 21.0003 13.4485 20.9999 14.0007C20.9991 14.9789 20.9932 15.7808 20.9304 16.4426C20.8664 17.116 20.7385 17.7136 20.455 18.2699C19.9757 19.2107 19.2108 19.9756 18.27 20.455C17.6777 20.7568 17.0375 20.8826 16.3086 20.9421C15.6008 21 14.7266 21 13.6428 21H10.3572C9.27339 21 8.39925 21 7.69138 20.9421C6.96253 20.8826 6.32234 20.7568 5.73005 20.455C4.78924 19.9756 4.02433 19.2107 3.54497 18.2699C3.24318 17.6776 3.11737 17.0374 3.05782 16.3086C2.99998 15.6007 2.99999 14.7266 3 13.6428V10.3572C2.99999 9.27337 2.99998 8.39922 3.05782 7.69134C3.11737 6.96249 3.24318 6.3223 3.54497 5.73001C4.02433 4.7892 4.78924 4.0243 5.73005 3.54493C6.28633 3.26149 6.88399 3.13358 7.55735 3.06961C8.21919 3.00673 9.02103 3.00083 9.99922 3.00007C10.5515 2.99964 10.9996 3.447 11 3.99929Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Github.tsx",
    "content": "export const GitHub = () => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 98 96\"\n      className=\"fill-text-primary\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      preserveAspectRatio=\"xMinYMin meet\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z\"\n      />\n    </svg>\n  );\n};\n\nexport default GitHub;\n"
  },
  {
    "path": "frontend/src/components/icons/Gitlab.tsx",
    "content": "export const Gitlab = () => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 380 380\"\n      width=\"24\"\n      height=\"24\"\n    >\n      <path\n        fill=\"#e24329\"\n        d=\"M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z\"\n      />\n      <path\n        fill=\"#fc6d26\"\n        d=\"M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z\"\n      />\n      <path\n        fill=\"#fca326\"\n        d=\"M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z\"\n      />\n      <path\n        fill=\"#fc6d26\"\n        d=\"M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z\"\n      />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Google.tsx",
    "content": "export const Google = () => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n    >\n      <path\n        d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n        fill=\"#4285F4\"\n      />\n      <path\n        d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n        fill=\"#34A853\"\n      />\n      <path\n        d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n        fill=\"#FBBC05\"\n      />\n      <path\n        d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n        fill=\"#EA4335\"\n      />\n      <path d=\"M1 1h22v22H1z\" fill=\"none\" />\n    </svg>\n  );\n};\nexport default Google;\n"
  },
  {
    "path": "frontend/src/components/icons/Microsoft.tsx",
    "content": "export const Microsoft = () => {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 23 23\">\n      <path fill=\"#f3f3f3\" d=\"M0 0h23v23H0z\" />\n      <path fill=\"#f35325\" d=\"M1 1h10v10H1z\" />\n      <path fill=\"#81bc06\" d=\"M12 1h10v10H12z\" />\n      <path fill=\"#05a6f0\" d=\"M1 12h10v10H1z\" />\n      <path fill=\"#ffba08\" d=\"M12 12h10v10H12z\" />\n    </svg>\n  );\n};\nexport default Microsoft;\n"
  },
  {
    "path": "frontend/src/components/icons/Okta.tsx",
    "content": "export const Okta = () => {\n  return (\n    <svg\n      version=\"1.2\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 1594 1595\"\n      width=\"24\"\n      height=\"24\"\n      style={{ fill: 'inherit' }}\n    >\n      <path\n        fillRule=\"evenodd\"\n        d=\"m877 11.5l-32.8 403.8c-15.5-1.8-31-2.7-46.9-2.7-19.9 0-39.4 1.3-58.5 4.4l-18.6-195.6c-0.4-6.2 4.5-11.6 10.7-11.6h33.2l-16-197.8c-0.4-6.2 4.5-11.6 10.2-11.6h108.5c6.2 0 11.1 5.4 10.2 11.1zm-166.1 410.4c-35 8-68.2 20.8-98.8 37.6l-84.6-177.5c-2.6-5.3 0-11.9 5.8-14.2l31.4-11.5-82.8-180.6c-2.6-5.3 0-11.9 5.8-14.2l101.9-37.2c5.7-2.2 11.9 1.4 13.7 7.1 0.4 0 107.6 390.5 107.6 390.5zm-357.4-278l234.3 330.2c-29.7 19.5-56.7 42.5-79.7 69.1l-140.5-138.1c-4.4-4.4-3.9-11.5 0.5-15.5l25.7-21.3-139.6-141.2c-4.4-4.4-3.9-11.5 0.9-15.5l82.9-69.5c4.8-4 11.5-3.1 15 1.8zm136.9 421.4c-21.3 27.9-38.5 58.9-51.4 92.1l-178.9-81.9c-5.8-2.2-8-9.3-4.9-14.6l16.8-28.8-179.8-85c-5.3-2.6-7.5-9.3-4.4-14.6l54-93.8c3.1-5.3 10.2-7.1 15.1-3.6zm-466 24.8c0.9-6.2 7.1-9.7 12.8-8.4l392 102.3c-10.2 33.2-15.9 68.2-16.8 104.5l-196.2-16c-6.2-0.4-10.7-6.2-9.3-12.4l5.7-32.7-198-18.6c-6.2-0.5-10.1-6.2-9.3-12.4l18.6-106.7zm-15 264.7l403.5-37.2c1.8 35.9 8.9 70.9 19.9 103.6l-189.5 52.3c-5.8 1.3-12-2.2-12.9-8.4l-5.7-32.8-192.3 50c-5.7 1.4-11.9-2.2-12.8-8.4l-19.1-106.7c-0.9-6.2 3.1-11.9 9.3-12.4zm63.4 280.7c-3.1-5.3-0.9-11.9 4.4-14.6l365.9-173.5c13.7 32.7 32.3 63.3 54.4 90.7l-160.3 114.2c-4.9 3.6-12 2.2-15.1-3.1l-16.8-29.2-163.4 112.9c-4.9 3.5-12 1.8-15.1-3.5 0 0-54.5-93.9-54-93.9zm525.7-9.3l-111.6 162c-3.5 5.4-10.6 6.2-15.5 2.3l-25.7-21.7-115.1 162c-3.6 4.9-10.2 5.8-15.1 1.8l-83.3-69.5c-4.8-4-5.3-11.1-0.9-15.5l284.8-288.2c24.4 25.6 52.3 48.2 82.4 66.8zm-138.6 395.8c-5.8-2.2-8.4-8.9-5.8-14.2l168.8-368.3c31 15.9 64.7 27.9 99.7 34.5l-49.7 190.4c-1.3 5.7-7.9 9.3-13.7 7.1l-31.4-11.5-52.7 191.7c-1.8 5.7-8 9.3-13.8 7l-101.8-37.1zm337.5-340c19.9 0 39.4-1.4 58.4-4.5l18.6 195.7c0.5 6.2-4.4 11.5-10.6 11.5h-33.2l15.9 197.9c0.9 6.2-3.9 11.5-10.1 11.5h-108.6c-5.7 0-10.6-5.3-10.2-11.5l32.8-403.7c15.5 2.2 31 3.1 47 3.1zm174.5-728.3c-31-15.5-64.2-27.5-99.7-34.5l49.6-190.4c1.8-5.8 8-9.3 13.8-7.1l31.4 11.5 52.7-191.7c1.8-5.7 8-9.3 13.8-7.1l101.8 37.2c5.8 2.2 8.5 8.4 5.8 14.2zm391.6-207.2l-284.9 288.2c-23.9-25.7-51.3-48.2-81.9-66.8l111.6-162.1c3.6-4.8 10.7-6.2 15.5-2.2l25.7 21.7 115.2-162c3.5-4.9 10.6-5.8 15-1.8l83.3 69.5c4.9 4 4.9 11.1 0.5 15.5zm153.7 227.1l-365.9 173.6c-14.2-32.8-32.3-63.4-54.5-90.8l160.3-114.2c4.9-4 12-2.2 15.1 3.1l16.8 28.8 163.5-112.9c4.9-3.1 11.9-1.8 15 3.5l54.5 93.9c3.1 5.3 1.4 11.9-4.4 14.6zm58 146.5l18.6 106.7c0.9 6.2-3.1 11.5-9.3 12.4l-403.5 37.6c-1.8-36.3-8.9-70.8-19.9-103.6l189.6-52.2c5.7-1.8 11.9 2.2 12.8 8.4l5.8 32.8 192.2-50.1c5.8-1.3 12 2.3 12.8 8.5zm-18.6 391.3l-392-102.3c10.2-33.2 16-68.1 16.9-104.4l196.2 15.9c6.2 0.9 10.2 6.2 9.3 12.4l-5.8 32.8 198 18.6c6.2 0.8 10.2 6.1 9.3 12.3l-18.6 106.7c-0.9 6.2-7.1 9.8-12.8 8.5zm-104.1 243.9c-3.1 5.3-10.2 6.6-15.1 3.5l-333.5-230.2c21.3-27.9 38.5-58.9 51.4-92.1l178.9 81.9c5.8 2.7 8 9.3 4.9 14.6l-16.8 28.8 179.8 85c5.3 2.7 7.5 9.3 4.4 14.6zm-446.5-135.9c29.7-19 56.3-42.5 79.8-69l140.4 138.1c4.4 4.4 4.4 11.5-0.5 15.5l-25.7 21.2 139.6 141.3c4 4.4 4 11.5-0.9 15.4l-82.8 69.6c-4.5 3.9-11.6 3.1-15.1-1.8l-234.3-330.3zm-1.8 449.8c-5.7 2.2-11.9-1.3-13.7-7.1l-107.2-390.4c35-8 68.2-20.8 98.8-37.7l84.6 177.6c2.6 5.7 0 12.4-5.8 14.1l-31.4 11.5 82.8 180.7c2.6 5.7 0 11.9-5.8 14.1l-101.8 37.2z\"\n      />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/PaperClip.tsx",
    "content": "export const PaperClip = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      className={className}\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M9 7C9 4.23858 11.2386 2 14 2C16.7614 2 19 4.23858 19 7V15C19 18.866 15.866 22 12 22C8.13401 22 5 18.866 5 15V9C5 8.44772 5.44772 8 6 8C6.55228 8 7 8.44772 7 9V15C7 17.7614 9.23858 20 12 20C14.7614 20 17 17.7614 17 15V7C17 5.34315 15.6569 4 14 4C12.3431 4 11 5.34315 11 7V15C11 15.5523 11.4477 16 12 16C12.5523 16 13 15.5523 13 15V9C13 8.44772 13.4477 8 14 8C14.5523 8 15 8.44772 15 9V15C15 16.6569 13.6569 18 12 18C10.3431 18 9 16.6569 9 15V7Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Pencil.tsx",
    "content": "export const Pencil = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M13.2929 4.29291C15.0641 2.52167 17.9359 2.52167 19.7071 4.2929C21.4784 6.06414 21.4784 8.93588 19.7071 10.7071L18.7073 11.7069L11.6135 18.8007C10.8766 19.5376 9.92793 20.0258 8.89999 20.1971L4.16441 20.9864C3.84585 21.0395 3.52127 20.9355 3.29291 20.7071C3.06454 20.4788 2.96053 20.1542 3.01362 19.8356L3.80288 15.1C3.9742 14.0721 4.46243 13.1234 5.19932 12.3865L13.2929 4.29291ZM13 7.41422L6.61353 13.8007C6.1714 14.2428 5.87846 14.8121 5.77567 15.4288L5.21656 18.7835L8.57119 18.2244C9.18795 18.1216 9.75719 17.8286 10.1993 17.3865L16.5858 11L13 7.41422ZM18 9.5858L14.4142 6.00001L14.7071 5.70712C15.6973 4.71693 17.3027 4.71693 18.2929 5.70712C19.2831 6.69731 19.2831 8.30272 18.2929 9.29291L18 9.5858Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Search.tsx",
    "content": "export const Search = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      className={className}\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M10.75 4.25C7.16015 4.25 4.25 7.16015 4.25 10.75C4.25 14.3399 7.16015 17.25 10.75 17.25C14.3399 17.25 17.25 14.3399 17.25 10.75C17.25 7.16015 14.3399 4.25 10.75 4.25ZM2.25 10.75C2.25 6.05558 6.05558 2.25 10.75 2.25C15.4444 2.25 19.25 6.05558 19.25 10.75C19.25 12.7369 18.5683 14.5645 17.426 16.0118L21.4571 20.0429C21.8476 20.4334 21.8476 21.0666 21.4571 21.4571C21.0666 21.8476 20.4334 21.8476 20.0429 21.4571L16.0118 17.426C14.5645 18.5683 12.7369 19.25 10.75 19.25C6.05558 19.25 2.25 15.4444 2.25 10.75Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Send.tsx",
    "content": "export const Send = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"32\"\n      height=\"32\"\n      viewBox=\"0 0 32 32\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M15.1918 8.90615C15.6381 8.45983 16.3618 8.45983 16.8081 8.90615L21.9509 14.049C22.3972 14.4953 22.3972 15.2189 21.9509 15.6652C21.5046 16.1116 20.781 16.1116 20.3347 15.6652L17.1428 12.4734V22.2857C17.1428 22.9169 16.6311 23.4286 15.9999 23.4286C15.3688 23.4286 14.8571 22.9169 14.8571 22.2857V12.4734L11.6652 15.6652C11.2189 16.1116 10.4953 16.1116 10.049 15.6652C9.60265 15.2189 9.60265 14.4953 10.049 14.049L15.1918 8.90615Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Settings.tsx",
    "content": "export const Settings = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      className={className}\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M11.5677 3.5C11.2129 3.5 10.8847 3.68789 10.7051 3.99377L9.89391 5.37524C9.3595 6.28538 8.38603 6.84786 7.3304 6.85645L5.73417 6.86945C5.3794 6.87233 5.0527 7.06288 4.87559 7.3702L4.43693 8.13135C4.2603 8.43784 4.25877 8.81481 4.43291 9.12273L5.22512 10.5235C5.74326 11.4397 5.74326 12.5603 5.22512 13.4765L4.43291 14.8773C4.25877 15.1852 4.2603 15.5622 4.43693 15.8687L4.87559 16.6298C5.0527 16.9371 5.3794 17.1277 5.73417 17.1306L7.33042 17.1436C8.38605 17.1522 9.35952 17.7146 9.89393 18.6248L10.7051 20.0062C10.8847 20.3121 11.2129 20.5 11.5677 20.5H12.4378C12.7926 20.5 13.1208 20.3121 13.3004 20.0062L14.1116 18.6248C14.646 17.7146 15.6195 17.1522 16.6751 17.1436L18.2714 17.1306C18.6262 17.1277 18.9529 16.9371 19.13 16.6298L19.5687 15.8687C19.7453 15.5622 19.7468 15.1852 19.5727 14.8773L18.7805 13.4765C18.2623 12.5603 18.2623 11.4397 18.7805 10.5235L19.5727 9.12273C19.7468 8.81481 19.7453 8.43784 19.5687 8.13135L19.13 7.3702C18.9529 7.06288 18.6262 6.87233 18.2714 6.86945L16.6751 6.85645C15.6195 6.84786 14.646 6.28538 14.1116 5.37524L13.3004 3.99377C13.1208 3.68788 12.7926 3.5 12.4378 3.5H11.5677ZM8.97978 2.98131C9.5186 2.06365 10.5033 1.5 11.5677 1.5H12.4378C13.5022 1.5 14.4869 2.06365 15.0257 2.98131L15.8369 4.36278C16.015 4.66616 16.3395 4.85365 16.6914 4.85652L18.2877 4.86951C19.352 4.87818 20.3321 5.4498 20.8635 6.37177L21.3021 7.13292C21.832 8.05239 21.8366 9.18331 21.3142 10.1071L20.522 11.5078C20.3493 11.8132 20.3493 12.1868 20.522 12.4922L21.3142 13.893C21.8366 14.8167 21.832 15.9476 21.3021 16.8671L20.8635 17.6282C20.3321 18.5502 19.352 19.1218 18.2877 19.1305L16.6914 19.1435C16.3395 19.1464 16.015 19.3339 15.8369 19.6372L15.0257 21.0187C14.4869 21.9363 13.5022 22.5 12.4378 22.5H11.5677C10.5033 22.5 9.5186 21.9363 8.97978 21.0187L8.16863 19.6372C7.99049 19.3339 7.666 19.1464 7.31413 19.1435L5.71789 19.1305C4.65357 19.1218 3.67347 18.5502 3.14213 17.6282L2.70347 16.8671C2.17358 15.9476 2.16899 14.8167 2.6914 13.893L3.48361 12.4922C3.65632 12.1868 3.65632 11.8132 3.48361 11.5078L2.6914 10.1071C2.16899 9.18331 2.17358 8.05239 2.70347 7.13292L3.14213 6.37177C3.67347 5.4498 4.65357 4.87818 5.71789 4.86951L7.31411 4.85652C7.66599 4.85366 7.99048 4.66616 8.16862 4.36278L8.97978 2.98131Z\"\n        fill=\"currentColor\"\n      ></path>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12.0028 10.5C11.1741 10.5 10.5024 11.1716 10.5024 12C10.5024 12.8284 11.1741 13.5 12.0028 13.5C12.8315 13.5 13.5032 12.8284 13.5032 12C13.5032 11.1716 12.8315 10.5 12.0028 10.5ZM8.50178 12C8.50178 10.067 10.0692 8.5 12.0028 8.5C13.9364 8.5 15.5038 10.067 15.5038 12C15.5038 13.933 13.9364 15.5 12.0028 15.5C10.0692 15.5 8.50178 13.933 8.50178 12Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Sidebar.tsx",
    "content": "export const Sidebar = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      className={className}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M8.85719 3H15.1428C16.2266 2.99999 17.1007 2.99998 17.8086 3.05782C18.5375 3.11737 19.1777 3.24318 19.77 3.54497C20.7108 4.02433 21.4757 4.78924 21.955 5.73005C22.2568 6.32234 22.3826 6.96253 22.4422 7.69138C22.5 8.39925 22.5 9.27339 22.5 10.3572V13.6428C22.5 14.7266 22.5 15.6008 22.4422 16.3086C22.3826 17.0375 22.2568 17.6777 21.955 18.27C21.4757 19.2108 20.7108 19.9757 19.77 20.455C19.1777 20.7568 18.5375 20.8826 17.8086 20.9422C17.1008 21 16.2266 21 15.1428 21H8.85717C7.77339 21 6.89925 21 6.19138 20.9422C5.46253 20.8826 4.82234 20.7568 4.23005 20.455C3.28924 19.9757 2.52433 19.2108 2.04497 18.27C1.74318 17.6777 1.61737 17.0375 1.55782 16.3086C1.49998 15.6007 1.49999 14.7266 1.5 13.6428V10.3572C1.49999 9.27341 1.49998 8.39926 1.55782 7.69138C1.61737 6.96253 1.74318 6.32234 2.04497 5.73005C2.52433 4.78924 3.28924 4.02433 4.23005 3.54497C4.82234 3.24318 5.46253 3.11737 6.19138 3.05782C6.89926 2.99998 7.77341 2.99999 8.85719 3ZM6.35424 5.05118C5.74907 5.10062 5.40138 5.19279 5.13803 5.32698C4.57354 5.6146 4.1146 6.07354 3.82698 6.63803C3.69279 6.90138 3.60062 7.24907 3.55118 7.85424C3.50078 8.47108 3.5 9.26339 3.5 10.4V13.6C3.5 14.7366 3.50078 15.5289 3.55118 16.1458C3.60062 16.7509 3.69279 17.0986 3.82698 17.362C4.1146 17.9265 4.57354 18.3854 5.13803 18.673C5.40138 18.8072 5.74907 18.8994 6.35424 18.9488C6.97108 18.9992 7.76339 19 8.9 19H9.5V5H8.9C7.76339 5 6.97108 5.00078 6.35424 5.05118ZM11.5 5V19H15.1C16.2366 19 17.0289 18.9992 17.6458 18.9488C18.2509 18.8994 18.5986 18.8072 18.862 18.673C19.4265 18.3854 19.8854 17.9265 20.173 17.362C20.3072 17.0986 20.3994 16.7509 20.4488 16.1458C20.4992 15.5289 20.5 14.7366 20.5 13.6V10.4C20.5 9.26339 20.4992 8.47108 20.4488 7.85424C20.3994 7.24907 20.3072 6.90138 20.173 6.63803C19.8854 6.07354 19.4265 5.6146 18.862 5.32698C18.5986 5.19279 18.2509 5.10062 17.6458 5.05118C17.0289 5.00078 16.2366 5 15.1 5H11.5ZM5 8.5C5 7.94772 5.44772 7.5 6 7.5H7C7.55229 7.5 8 7.94772 8 8.5C8 9.05229 7.55229 9.5 7 9.5H6C5.44772 9.5 5 9.05229 5 8.5ZM5 12C5 11.4477 5.44772 11 6 11H7C7.55229 11 8 11.4477 8 12C8 12.5523 7.55229 13 7 13H6C5.44772 13 5 12.5523 5 12Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/Stop.tsx",
    "content": "export const Stop = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect\n        x=\"7\"\n        y=\"7\"\n        width=\"10\"\n        height=\"10\"\n        rx=\"1.25\"\n        fill=\"currentColor\"\n      ></rect>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/ToolBox.tsx",
    "content": "export const ToolBox = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <mask id=\"stuff-part-box-fill\">\n        <path d=\"M-6 -10H30V11H-6Z\" fill=\"white\"></path>\n        <path\n          d=\"M20 11H4V19C4 20.1 4.9 21 6 21H18C19.1 21 20 20.1 20 19V11Z\"\n          fill=\"black\"\n        ></path>\n      </mask>\n      <mask id=\"stuff-part-star-fill\">\n        <path d=\"M-6 -6H30V11H-6Z\" fill=\"white\"></path>\n        <path\n          d=\"M8.05625 3.61554C8.30853 2.89184 9.2477 2.70929 9.75272 3.28578L11.2938 5.04497C11.4788 5.25615 11.744 5.37984 12.0247 5.38581L14.3629 5.43554C15.1291 5.45184 15.593 6.28864 15.2008 6.94708L14.0039 8.95633C13.8602 9.19753 13.8245 9.48802 13.9056 9.75682L14.5808 11.9959C14.8021 12.7297 14.1496 13.4294 13.4022 13.2599L11.1214 12.7425C10.8476 12.6804 10.5603 12.7362 10.3297 12.8964L8.40888 14.2305C7.7794 14.6677 6.91229 14.2633 6.84258 13.5001L6.62986 11.1711C6.60432 10.8915 6.46243 10.6355 6.23886 10.4657L4.37646 9.05111C3.76614 8.58754 3.88274 7.63792 4.58708 7.33577L6.73638 6.41376C6.99439 6.30307 7.19399 6.08903 7.28641 5.82392L8.05625 3.61554Z\"\n          fill=\"black\"\n        ></path>\n      </mask>\n      <g mask=\"url(#stuff-part-box-fill)\">\n        <g mask=\"url(#stuff-part-star-fill)\">\n          <path\n            fill-rule=\"evenodd\"\n            clip-rule=\"evenodd\"\n            d=\"M14.1356 3.327C14.6861 2.06081 16.1589 1.48067 17.4251 2.03123L19.4885 2.92841C20.7547 3.47897 21.3348 4.95174 20.7842 6.21793L17.8342 13.0027C17.663 13.3963 17.3934 13.7392 17.0513 13.9984L13.054 17.026L12.4501 16.2289L11.4544 16.3212L11.3505 15.2023C11.295 14.6055 11.2326 13.9364 11.2047 13.6425L10.987 11.3504C10.947 10.9294 11.0145 10.505 11.1831 10.1172L14.1356 3.327ZM12.4501 16.2289L11.4544 16.3212C11.4879 16.6826 11.7146 16.9975 12.0468 17.1439C12.3789 17.2903 12.7646 17.245 13.054 17.026L12.4501 16.2289ZM13.2795 14.3467L15.8435 12.4043C15.9119 12.3525 15.9658 12.2839 16 12.2052L18.9501 5.42044C19.0602 5.1672 18.9442 4.87264 18.691 4.76253L16.6276 3.86535C16.3743 3.75524 16.0798 3.87126 15.9697 4.1245L13.0172 10.9147C12.9835 10.9922 12.97 11.0771 12.978 11.1613L13.1957 13.4533C13.2135 13.6399 13.2448 13.9752 13.2795 14.3467Z\"\n            fill=\"currentColor\"\n          ></path>\n        </g>\n        <path\n          fill-rule=\"evenodd\"\n          clip-rule=\"evenodd\"\n          d=\"M7.11197 3.28637C7.61654 1.83896 9.49489 1.47385 10.5049 2.62685L12.046 4.38603L14.3842 4.43577C15.9166 4.46836 16.8443 6.14195 16.0599 7.45885L14.863 9.4681L15.5382 11.7072C15.9808 13.1748 14.6758 14.5742 13.181 14.2351L10.9002 13.7177L8.97932 15.0518C7.72036 15.9262 5.98614 15.1175 5.84672 13.5911L5.634 11.262L3.7716 9.84744C2.55096 8.9203 2.78416 7.02106 4.19284 6.41676L6.34213 5.49475L7.11197 3.28637ZM10.5416 5.7039L9.00051 3.94471L8.23067 6.15309C8.04584 6.6833 7.64664 7.1114 7.13061 7.33276L4.98132 8.25478L6.84371 9.66936C7.29086 10.009 7.57464 10.5209 7.62571 11.0801L7.83843 13.4091L9.75929 12.075C10.2205 11.7547 10.7951 11.643 11.3427 11.7673L13.6234 12.2847L12.9482 10.0455C12.7861 9.50795 12.8574 8.92697 13.1448 8.44457L14.3416 6.43532L12.0034 6.38558C11.4421 6.37364 10.9116 6.12626 10.5416 5.7039Z\"\n          fill=\"currentColor\"\n        ></path>\n        <g>\n          <path\n            fill-rule=\"evenodd\"\n            clip-rule=\"evenodd\"\n            d=\"M11.4873 26.067C11.5514 26.0445 11.6198 26.0217 11.6925 25.9988L11.0922 24.091C10.6826 24.2199 10.239 24.3899 9.87818 24.6169C9.58085 24.804 9 25.243 9 26C9 26.7396 9.49534 27.2202 9.86829 27.4784C9.87463 27.4828 9.88099 27.4872 9.88737 27.4915C9.86663 27.5058 9.84613 27.5203 9.82589 27.535C9.45161 27.8072 9 28.2875 9 29C9 29.7396 9.49534 30.2202 9.86829 30.4784C9.90016 30.5005 9.93264 30.522 9.96568 30.5431C9.93938 30.5594 9.91347 30.5759 9.888 30.5926C9.70054 30.7159 9.49716 30.8773 9.33217 31.0864C9.16471 31.2986 9 31.6092 9 32C9 32.3908 9.16471 32.7014 9.33217 32.9136C9.49716 33.1227 9.70054 33.2841 9.888 33.4074C10.263 33.654 10.7308 33.8492 11.1866 33.9996C12.0976 34.3003 13.2047 34.5 14 34.5V32.5C13.462 32.5 12.569 32.3497 11.8134 32.1004C11.7126 32.0671 11.6187 32.0335 11.5319 32C11.6187 31.9665 11.7126 31.9329 11.8134 31.8996C12.569 31.6503 13.462 31.5 14 31.5C14.5523 31.5 15 31.0523 15 30.5C15 29.9477 14.5523 29.5 14 29.5C13.4254 29.5 12.5226 29.3877 11.7873 29.1672C11.5941 29.1092 11.4306 29.049 11.2974 28.9903C11.4159 28.9376 11.5604 28.8834 11.7316 28.8308C12.4366 28.6139 13.336 28.5 14 28.5C14.5523 28.5 15 28.0523 15 27.5C15 26.9477 14.5523 26.5 14 26.5C13.4254 26.5 12.5226 26.3877 11.7873 26.1672C11.6777 26.1343 11.5776 26.1007 11.4873 26.067Z\"\n            fill=\"currentColor\"\n          ></path>\n          <path\n            fill-rule=\"evenodd\"\n            clip-rule=\"evenodd\"\n            d=\"M13.5 10.382V12C13.5 12.5167 13.5053 13.1354 13.6523 13.6744C13.7561 14.0553 13.8996 14.2919 14.0859 14.4317C14.8816 14.222 15.8088 14.1322 16.7039 14.2601C17.7583 14.4107 18.8748 14.8844 19.582 15.9453L20.3759 17.1361L18.9847 17.4721C18.5941 17.5664 18.3944 17.6948 18.2874 17.7953C18.1845 17.8918 18.1122 18.0138 18.0619 18.1945C18.0076 18.3896 17.9843 18.6347 17.9822 18.955C17.9812 19.1127 17.9852 19.2749 17.9899 19.452L17.9909 19.4879C17.9952 19.6499 18 19.8262 18 20C18 23.3137 15.3137 26 12 26C8.6863 26 6.00001 23.3137 6.00001 20C6.00001 19.8108 6.00384 19.6603 6.00738 19.5217C6.01201 19.3399 6.01612 19.1786 6.01036 18.9771C6.00164 18.6724 5.96752 18.4502 5.90286 18.2781C5.84523 18.1246 5.75703 17.9926 5.5915 17.8702C5.4129 17.7381 5.10246 17.5873 4.55908 17.4816L3.12725 17.2031L3.90325 15.968C4.95025 14.3016 6.56507 13.9881 7.77695 14.0645C8.18753 14.0904 8.56505 14.1599 8.88234 14.2391C9.66993 12.4933 11.119 11.5725 12.0528 11.1056L13.5 10.382ZM6.65071 16.1709C6.69502 16.2004 6.73833 16.2308 6.78066 16.2621C7.27801 16.6299 7.59164 17.0862 7.77518 17.5749C7.95168 18.0449 7.99806 18.5188 8.00954 18.9199C8.01605 19.1476 8.01011 19.4317 8.00512 19.6699C8.00242 19.7989 8.00001 19.9143 8.00001 20C8.00001 22.2091 9.79087 24 12 24C14.2091 24 16 22.2091 16 20C16 19.8543 15.996 19.7036 15.9914 19.5349L15.9906 19.5057C15.9859 19.3305 15.981 19.1373 15.9823 18.9421C15.9848 18.5565 16.0107 18.1055 16.1351 17.6584C16.2623 17.2011 16.495 16.739 16.9075 16.3475C16.7574 16.3007 16.5951 16.2648 16.4211 16.2399C15.7217 16.14 14.9416 16.2402 14.3162 16.4487C14.111 16.5171 13.889 16.5171 13.6838 16.4487C12.4861 16.0495 11.9545 15.0504 11.7227 14.2006C11.6855 14.064 11.6545 13.9262 11.6286 13.7891C11.1253 14.2598 10.6832 14.8905 10.4701 15.7425C10.3975 16.0329 10.1987 16.2755 9.92819 16.4037C9.65885 16.5313 9.34669 16.5321 9.07678 16.406Z  M11 19.5C11 20.0523 10.6642 20.5 10.25 20.5C9.83579 20.5 9.5 20.0523 9.5 19.5C9.5 18.9477 9.83579 18.5 10.25 18.5C10.6642 18.5 11 18.9477 11 19.5Z M14.5 19.5C14.5 20.0523 14.1642 20.5 13.75 20.5C13.3358 20.5 13 20.0523 13 19.5C13 18.9477 13.3358 18.5 13.75 18.5C14.1642 18.5 14.5 18.9477 14.5 19.5Z\"\n            fill=\"currentColor\"\n          ></path>\n        </g>\n      </g>\n      <path\n        fill-rule=\"evenodd\"\n        clip-rule=\"evenodd\"\n        d=\"M3 11C3 10.4477 3.44772 10 4 10H20C20.5523 10 21 10.4477 21 11V19C21 20.6523 19.6523 22 18 22H6C4.34772 22 3 20.6523 3 19V11ZM5 12V19C5 19.5477 5.45228 20 6 20H18C18.5477 20 19 19.5477 19 19V12H5ZM9.5 14.5C9.5 13.9477 9.94772 13.5 10.5 13.5H13.5C14.0523 13.5 14.5 13.9477 14.5 14.5C14.5 15.0523 14.0523 15.5 13.5 15.5H10.5C9.94772 15.5 9.5 15.0523 9.5 14.5Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/VoiceLines.tsx",
    "content": "export const VoiceLines = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M9.5 4C8.67157 4 8 4.67157 8 5.5V18.5C8 19.3284 8.67157 20 9.5 20C10.3284 20 11 19.3284 11 18.5V5.5C11 4.67157 10.3284 4 9.5 4Z\"\n        fill=\"currentColor\"\n      ></path>\n      <path\n        d=\"M13 8.5C13 7.67157 13.6716 7 14.5 7C15.3284 7 16 7.67157 16 8.5V15.5C16 16.3284 15.3284 17 14.5 17C13.6716 17 13 16.3284 13 15.5V8.5Z\"\n        fill=\"currentColor\"\n      ></path>\n      <path\n        d=\"M4.5 9C3.67157 9 3 9.67157 3 10.5V13.5C3 14.3284 3.67157 15 4.5 15C5.32843 15 6 14.3284 6 13.5V10.5C6 9.67157 5.32843 9 4.5 9Z\"\n        fill=\"currentColor\"\n      ></path>\n      <path\n        d=\"M19.5 9C18.6716 9 18 9.67157 18 10.5V13.5C18 14.3284 18.6716 15 19.5 15C20.3284 15 21 14.3284 21 13.5V10.5C21 9.67157 20.3284 9 19.5 9Z\"\n        fill=\"currentColor\"\n      ></path>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/share/ShareDialog.tsx",
    "content": "import { useCallback, useContext, useMemo, useState } from 'react';\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\n\nimport {\n  ChainlitContext,\n  ClientError,\n  threadHistoryState\n} from '@chainlit/react-client';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/dialog';\n\nimport { Translator } from '../i18n';\n\ntype ShareDialogProps = {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  threadId?: string | null;\n};\n\nexport function ShareDialog({\n  open,\n  onOpenChange,\n  threadId\n}: ShareDialogProps) {\n  const apiClient = useContext(ChainlitContext);\n  const [isCopying, setIsCopying] = useState(false);\n  const [isCopied, setIsCopied] = useState(false);\n  const [hasBeenCopied, setHasBeenCopied] = useState(false);\n  const [sharedThreadId, setSharedThreadId] = useState<string | null>(null);\n  const threadHistory = useRecoilValue(threadHistoryState);\n  const setThreadHistory = useSetRecoilState(threadHistoryState);\n\n  const isAlreadyShared = useMemo(() => {\n    if (!threadId) return false;\n    const allThreads = threadHistory?.threads;\n    if (!allThreads) return false;\n    const t = allThreads.find((th) => th.id === threadId);\n    return Boolean(t?.metadata?.is_shared);\n  }, [threadHistory?.threads, threadId]);\n\n  const shareLink = useMemo(() => {\n    const id = sharedThreadId || threadId || '';\n    return `${window.location.origin}/share/${id}`;\n  }, [sharedThreadId, threadId]);\n\n  const handleCopy = async () => {\n    if (!threadId) return;\n    try {\n      if (!hasBeenCopied) {\n        if (isAlreadyShared) {\n          setSharedThreadId(threadId);\n          await navigator.clipboard.writeText(shareLink);\n          setHasBeenCopied(true);\n          setIsCopied(true);\n          setTimeout(() => setIsCopied(false), 2000);\n          toast.success(\n            <Translator path=\"threadHistory.thread.actions.share.status.copied\" />\n          );\n          return;\n        }\n        setIsCopying(true);\n        if (typeof (apiClient as any)?.shareThread === 'function') {\n          await (apiClient as any).shareThread(threadId, true);\n        } else {\n          const putRes = await (apiClient as any).put?.(\n            `/project/thread/share`,\n            {\n              threadId,\n              isShared: true\n            }\n          );\n          await putRes?.json?.();\n        }\n        setSharedThreadId(threadId);\n        await navigator.clipboard.writeText(shareLink);\n        await new Promise((resolve) => setTimeout(resolve, 1000));\n        setIsCopying(false);\n        setHasBeenCopied(true);\n        setThreadHistory((prev) => {\n          if (!prev?.threads) return prev;\n          const next = { ...prev, threads: [...prev.threads] };\n          const idx = next.threads.findIndex((t) => t.id === threadId);\n          if (idx !== -1) {\n            const md = { ...(next.threads[idx].metadata || {}) };\n            md.is_shared = true;\n            next.threads[idx] = { ...next.threads[idx], metadata: md } as any;\n          }\n          return next;\n        });\n        toast.success(\n          <Translator path=\"threadHistory.thread.actions.share.status.created\" />\n        );\n      } else {\n        await navigator.clipboard.writeText(shareLink);\n      }\n      setIsCopied(true);\n      setTimeout(() => setIsCopied(false), 2000);\n    } catch (err: any) {\n      setIsCopying(false);\n      if (err instanceof ClientError) {\n        // Show server-provided detail when available\n        toast.error(err.toString());\n      } else {\n        toast.error(\n          <Translator path=\"threadHistory.thread.actions.share.error.create\" />\n        );\n      }\n    }\n  };\n\n  const handleUnshare = useCallback(async () => {\n    if (!threadId) return;\n    try {\n      setIsCopying(true);\n      if (typeof (apiClient as any)?.shareThread === 'function') {\n        await (apiClient as any).shareThread(threadId, false);\n      } else {\n        const putRes = await (apiClient as any).put?.(`/project/thread/share`, {\n          threadId,\n          isShared: false\n        });\n        await putRes?.json?.();\n      }\n      setThreadHistory((prev) => {\n        if (!prev?.threads) return prev;\n        const next = { ...prev, threads: [...prev.threads] };\n        const idx = next.threads.findIndex((t) => t.id === threadId);\n        if (idx !== -1) {\n          const md = { ...(next.threads[idx].metadata || {}) };\n          md.is_shared = false;\n          if ('shared_at' in md) delete (md as any).shared_at;\n          next.threads[idx] = { ...next.threads[idx], metadata: md } as any;\n        }\n        return next;\n      });\n      setIsCopying(false);\n      toast.success(\n        <Translator path=\"threadHistory.thread.actions.share.status.unshared\" />\n      );\n      onOpenChange(false);\n    } catch (err: any) {\n      setIsCopying(false);\n      if (err instanceof ClientError) {\n        toast.error(err.toString());\n      } else {\n        toast.error(\n          <Translator path=\"threadHistory.thread.actions.share.error.unshare\" />\n        );\n      }\n    }\n  }, [apiClient, onOpenChange, setThreadHistory, threadId]);\n\n  return (\n    <Dialog\n      open={open}\n      onOpenChange={(o) => {\n        onOpenChange(o);\n        if (!o) {\n          setSharedThreadId(null);\n          setIsCopying(false);\n          setIsCopied(false);\n          setHasBeenCopied(false);\n        }\n      }}\n    >\n      <DialogContent className=\"flex flex-col sm:max-w-lg\">\n        <DialogHeader>\n          <DialogTitle>\n            <Translator path=\"threadHistory.thread.actions.share.title\" />\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"flex flex-col gap-3 w-full\">\n          <div className=\"rounded-md border px-3 py-2 w-full\">\n            <span\n              className=\"text-sm text-muted-foreground overflow-hidden text-ellipsis whitespace-nowrap block\"\n              title={shareLink}\n            >\n              {shareLink}\n            </span>\n          </div>\n          <div className=\"flex gap-2 justify-center\">\n            <Button\n              onClick={handleCopy}\n              disabled={!threadId || isCopying || isCopied}\n            >\n              <Translator path=\"threadHistory.thread.actions.share.button\" />\n            </Button>\n            {isAlreadyShared ? (\n              <Button\n                onClick={handleUnshare}\n                disabled={!threadId || isCopying}\n                variant=\"outline\"\n              >\n                Unshare\n              </Button>\n            ) : null}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nexport default ShareDialog;\n"
  },
  {
    "path": "frontend/src/components/ui/accordion.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as AccordionPrimitive from '@radix-ui/react-accordion';\nimport { ChevronDown } from 'lucide-react';\nimport * as React from 'react';\n\nconst Accordion = AccordionPrimitive.Root;\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn('border-b', className)}\n    {...props}\n  />\n));\nAccordionItem.displayName = 'AccordionItem';\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n));\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn('pb-4 pt-0', className)}>{children}</div>\n  </AccordionPrimitive.Content>\n));\n\nAccordionContent.displayName = AccordionPrimitive.Content.displayName;\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n"
  },
  {
    "path": "frontend/src/components/ui/alert-dialog.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';\nimport * as React from 'react';\n\nimport { buttonVariants } from '@/components/ui/button';\n\nconst AlertDialog = AlertDialogPrimitive.Root;\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger;\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal;\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      'fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\n\nconst AlertDialogContent = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPortal container={window.cl_shadowRootElement}>\n    <AlertDialogOverlay />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n        className\n      )}\n      {...props}\n    />\n  </AlertDialogPortal>\n));\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\n\nconst AlertDialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col space-y-2 text-center sm:text-left',\n      className\n    )}\n    {...props}\n  />\n);\nAlertDialogHeader.displayName = 'AlertDialogHeader';\n\nconst AlertDialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',\n      className\n    )}\n    {...props}\n  />\n);\nAlertDialogFooter.displayName = 'AlertDialogFooter';\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn('text-lg font-semibold', className)}\n    {...props}\n  />\n));\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nAlertDialogDescription.displayName =\n  AlertDialogPrimitive.Description.displayName;\n\nconst AlertDialogAction = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action\n    ref={ref}\n    className={cn(buttonVariants(), className)}\n    {...props}\n  />\n));\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(\n      buttonVariants({ variant: 'outline' }),\n      'mt-2 sm:mt-0',\n      className\n    )}\n    {...props}\n  />\n));\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel\n};\n"
  },
  {
    "path": "frontend/src/components/ui/aspect-ratio.tsx",
    "content": "import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';\n\nconst AspectRatio = AspectRatioPrimitive.Root;\n\nexport { AspectRatio };\n"
  },
  {
    "path": "frontend/src/components/ui/avatar.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as AvatarPrimitive from '@radix-ui/react-avatar';\nimport * as React from 'react';\n\nconst Avatar = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Root\n    ref={ref}\n    className={cn(\n      'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',\n      className\n    )}\n    {...props}\n  />\n));\nAvatar.displayName = AvatarPrimitive.Root.displayName;\n\nconst AvatarImage = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Image>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Image\n    ref={ref}\n    className={cn('aspect-square h-full w-full', className)}\n    {...props}\n  />\n));\nAvatarImage.displayName = AvatarPrimitive.Image.displayName;\n\nconst AvatarFallback = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Fallback>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Fallback\n    ref={ref}\n    className={cn(\n      'flex h-full w-full items-center justify-center rounded-full bg-muted',\n      className\n    )}\n    {...props}\n  />\n));\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;\n\nexport { Avatar, AvatarImage, AvatarFallback };\n"
  },
  {
    "path": "frontend/src/components/ui/badge.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport * as React from 'react';\n\nconst badgeVariants = cva(\n  'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',\n  {\n    variants: {\n      variant: {\n        default:\n          'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        destructive:\n          'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',\n        outline: 'text-foreground'\n      }\n    },\n    defaultVariants: {\n      variant: 'default'\n    }\n  }\n);\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "frontend/src/components/ui/button.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Slot } from '@radix-ui/react-slot';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport * as React from 'react';\n\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive:\n          'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n        outline:\n          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',\n        secondary:\n          'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground',\n        link: 'text-primary underline-offset-4 hover:underline'\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        sm: 'h-9 rounded-md px-3',\n        lg: 'h-11 rounded-md px-8',\n        icon: 'h-9 w-9'\n      }\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default'\n    }\n  }\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : 'button';\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    );\n  }\n);\nButton.displayName = 'Button';\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "frontend/src/components/ui/calendar.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport {\n  ChevronDownIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon\n} from 'lucide-react';\nimport * as React from 'react';\nimport { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';\n\nimport { Button, buttonVariants } from '@/components/ui/button';\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  captionLayout = 'label',\n  buttonVariant = 'ghost',\n  formatters,\n  components,\n  ...props\n}: React.ComponentProps<typeof DayPicker> & {\n  buttonVariant?: React.ComponentProps<typeof Button>['variant'];\n}) {\n  const defaultClassNames = getDefaultClassNames();\n\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\n        'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',\n        String.raw`rtl:**:[.rdp-button\\_next>svg]:rotate-180`,\n        String.raw`rtl:**:[.rdp-button\\_previous>svg]:rotate-180`,\n        className\n      )}\n      captionLayout={captionLayout}\n      formatters={{\n        formatMonthDropdown: (date) =>\n          date.toLocaleString('default', { month: 'short' }),\n        ...formatters\n      }}\n      classNames={{\n        root: cn('w-fit', defaultClassNames.root),\n        months: cn(\n          'relative flex flex-col gap-4 md:flex-row',\n          defaultClassNames.months\n        ),\n        month: cn('flex w-full flex-col gap-4', defaultClassNames.month),\n        nav: cn(\n          'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',\n          defaultClassNames.nav\n        ),\n        button_previous: cn(\n          buttonVariants({ variant: buttonVariant }),\n          'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',\n          defaultClassNames.button_previous\n        ),\n        button_next: cn(\n          buttonVariants({ variant: buttonVariant }),\n          'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',\n          defaultClassNames.button_next\n        ),\n        month_caption: cn(\n          'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',\n          defaultClassNames.month_caption\n        ),\n        dropdowns: cn(\n          'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',\n          defaultClassNames.dropdowns\n        ),\n        dropdown_root: cn(\n          'has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border',\n          defaultClassNames.dropdown_root\n        ),\n        dropdown: cn(\n          'bg-popover absolute inset-0 opacity-0',\n          defaultClassNames.dropdown\n        ),\n        caption_label: cn(\n          'select-none font-medium',\n          captionLayout === 'label'\n            ? 'text-sm'\n            : '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',\n          defaultClassNames.caption_label\n        ),\n        table: 'w-full border-collapse',\n        weekdays: cn('flex', defaultClassNames.weekdays),\n        weekday: cn(\n          'text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal',\n          defaultClassNames.weekday\n        ),\n        week: cn('mt-2 flex w-full', defaultClassNames.week),\n        week_number_header: cn(\n          'w-[--cell-size] select-none',\n          defaultClassNames.week_number_header\n        ),\n        week_number: cn(\n          'text-muted-foreground select-none text-[0.8rem]',\n          defaultClassNames.week_number\n        ),\n        day: cn(\n          'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',\n          defaultClassNames.day\n        ),\n        range_start: cn(\n          'bg-accent rounded-l-md',\n          defaultClassNames.range_start\n        ),\n        range_middle: cn('rounded-none', defaultClassNames.range_middle),\n        range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),\n        today: cn(\n          'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',\n          defaultClassNames.today\n        ),\n        outside: cn(\n          'text-muted-foreground aria-selected:text-muted-foreground',\n          defaultClassNames.outside\n        ),\n        disabled: cn(\n          'text-muted-foreground opacity-50',\n          defaultClassNames.disabled\n        ),\n        hidden: cn('invisible', defaultClassNames.hidden),\n        ...classNames\n      }}\n      components={{\n        Root: ({ className, rootRef, ...props }) => {\n          return (\n            <div\n              data-slot=\"calendar\"\n              ref={rootRef}\n              className={cn(className)}\n              {...props}\n            />\n          );\n        },\n        Chevron: ({ className, orientation, ...props }) => {\n          if (orientation === 'left') {\n            return (\n              <ChevronLeftIcon className={cn('size-4', className)} {...props} />\n            );\n          }\n\n          if (orientation === 'right') {\n            return (\n              <ChevronRightIcon\n                className={cn('size-4', className)}\n                {...props}\n              />\n            );\n          }\n\n          return (\n            <ChevronDownIcon className={cn('size-4', className)} {...props} />\n          );\n        },\n        DayButton: CalendarDayButton,\n        WeekNumber: ({ children, ...props }) => {\n          return (\n            <td {...props}>\n              <div className=\"flex size-[--cell-size] items-center justify-center text-center\">\n                {children}\n              </div>\n            </td>\n          );\n        },\n        ...components\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction CalendarDayButton({\n  className,\n  day,\n  modifiers,\n  ...props\n}: React.ComponentProps<typeof DayButton>) {\n  const defaultClassNames = getDefaultClassNames();\n\n  const ref = React.useRef<HTMLButtonElement>(null);\n  React.useEffect(() => {\n    if (modifiers.focused) ref.current?.focus();\n  }, [modifiers.focused]);\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size=\"icon\"\n      data-day={day.date.toLocaleDateString()}\n      data-selected-single={\n        modifiers.selected &&\n        !modifiers.range_start &&\n        !modifiers.range_end &&\n        !modifiers.range_middle\n      }\n      data-range-start={modifiers.range_start}\n      data-range-end={modifiers.range_end}\n      data-range-middle={modifiers.range_middle}\n      className={cn(\n        'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70',\n        defaultClassNames.day,\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Calendar, CalendarDayButton };\n"
  },
  {
    "path": "frontend/src/components/ui/card.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as React from 'react';\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      'rounded-lg border bg-card text-card-foreground shadow-sm',\n      className\n    )}\n    {...props}\n  />\n));\nCard.displayName = 'Card';\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex flex-col space-y-1.5 p-6', className)}\n    {...props}\n  />\n));\nCardHeader.displayName = 'CardHeader';\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      'text-2xl font-semibold leading-none tracking-tight',\n      className\n    )}\n    {...props}\n  />\n));\nCardTitle.displayName = 'CardTitle';\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nCardDescription.displayName = 'CardDescription';\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />\n));\nCardContent.displayName = 'CardContent';\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn('flex items-center p-6 pt-0', className)}\n    {...props}\n  />\n));\nCardFooter.displayName = 'CardFooter';\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardDescription,\n  CardContent\n};\n"
  },
  {
    "path": "frontend/src/components/ui/carousel.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType\n} from 'embla-carousel-react';\nimport { ArrowLeft, ArrowRight } from 'lucide-react';\nimport * as React from 'react';\n\nimport { Button } from '@/components/ui/button';\n\ntype CarouselApi = UseEmblaCarouselType[1];\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>;\ntype CarouselOptions = UseCarouselParameters[0];\ntype CarouselPlugin = UseCarouselParameters[1];\n\ntype CarouselProps = {\n  opts?: CarouselOptions;\n  plugins?: CarouselPlugin;\n  orientation?: 'horizontal' | 'vertical';\n  setApi?: (api: CarouselApi) => void;\n};\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0];\n  api: ReturnType<typeof useEmblaCarousel>[1];\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  canScrollPrev: boolean;\n  canScrollNext: boolean;\n} & CarouselProps;\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null);\n\nfunction useCarousel() {\n  const context = React.useContext(CarouselContext);\n\n  if (!context) {\n    throw new Error('useCarousel must be used within a <Carousel />');\n  }\n\n  return context;\n}\n\nconst Carousel = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & CarouselProps\n>(\n  (\n    {\n      orientation = 'horizontal',\n      opts,\n      setApi,\n      plugins,\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const [carouselRef, api] = useEmblaCarousel(\n      {\n        ...opts,\n        axis: orientation === 'horizontal' ? 'x' : 'y'\n      },\n      plugins\n    );\n    const [canScrollPrev, setCanScrollPrev] = React.useState(false);\n    const [canScrollNext, setCanScrollNext] = React.useState(false);\n\n    const onSelect = React.useCallback((api: CarouselApi) => {\n      if (!api) {\n        return;\n      }\n\n      setCanScrollPrev(api.canScrollPrev());\n      setCanScrollNext(api.canScrollNext());\n    }, []);\n\n    const scrollPrev = React.useCallback(() => {\n      api?.scrollPrev();\n    }, [api]);\n\n    const scrollNext = React.useCallback(() => {\n      api?.scrollNext();\n    }, [api]);\n\n    const handleKeyDown = React.useCallback(\n      (event: React.KeyboardEvent<HTMLDivElement>) => {\n        if (event.key === 'ArrowLeft') {\n          event.preventDefault();\n          scrollPrev();\n        } else if (event.key === 'ArrowRight') {\n          event.preventDefault();\n          scrollNext();\n        }\n      },\n      [scrollPrev, scrollNext]\n    );\n\n    React.useEffect(() => {\n      if (!api || !setApi) {\n        return;\n      }\n\n      setApi(api);\n    }, [api, setApi]);\n\n    React.useEffect(() => {\n      if (!api) {\n        return;\n      }\n\n      onSelect(api);\n      api.on('reInit', onSelect);\n      api.on('select', onSelect);\n\n      return () => {\n        api?.off('select', onSelect);\n      };\n    }, [api, onSelect]);\n\n    return (\n      <CarouselContext.Provider\n        value={{\n          carouselRef,\n          api: api,\n          opts,\n          orientation:\n            orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),\n          scrollPrev,\n          scrollNext,\n          canScrollPrev,\n          canScrollNext\n        }}\n      >\n        <div\n          ref={ref}\n          onKeyDownCapture={handleKeyDown}\n          className={cn('relative', className)}\n          role=\"region\"\n          aria-roledescription=\"carousel\"\n          {...props}\n        >\n          {children}\n        </div>\n      </CarouselContext.Provider>\n    );\n  }\n);\nCarousel.displayName = 'Carousel';\n\nconst CarouselContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { carouselRef, orientation } = useCarousel();\n\n  return (\n    <div ref={carouselRef} className=\"overflow-hidden\">\n      <div\n        ref={ref}\n        className={cn(\n          'flex',\n          orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',\n          className\n        )}\n        {...props}\n      />\n    </div>\n  );\n});\nCarouselContent.displayName = 'CarouselContent';\n\nconst CarouselItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { orientation } = useCarousel();\n\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      className={cn(\n        'min-w-0 shrink-0 grow-0 basis-full',\n        orientation === 'horizontal' ? 'pl-4' : 'pt-4',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nCarouselItem.displayName = 'CarouselItem';\n\nconst CarouselPrevious = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {\n  const { orientation, scrollPrev, canScrollPrev } = useCarousel();\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        'absolute  h-8 w-8 rounded-full',\n        orientation === 'horizontal'\n          ? '-left-12 top-1/2 -translate-y-1/2'\n          : '-top-12 left-1/2 -translate-x-1/2 rotate-90',\n        className\n      )}\n      disabled={!canScrollPrev}\n      onClick={scrollPrev}\n      {...props}\n    >\n      <ArrowLeft className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Previous slide</span>\n    </Button>\n  );\n});\nCarouselPrevious.displayName = 'CarouselPrevious';\n\nconst CarouselNext = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {\n  const { orientation, scrollNext, canScrollNext } = useCarousel();\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        'absolute h-8 w-8 rounded-full',\n        orientation === 'horizontal'\n          ? '-right-12 top-1/2 -translate-y-1/2'\n          : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',\n        className\n      )}\n      disabled={!canScrollNext}\n      onClick={scrollNext}\n      {...props}\n    >\n      <ArrowRight className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Next slide</span>\n    </Button>\n  );\n});\nCarouselNext.displayName = 'CarouselNext';\n\nexport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext\n};\n"
  },
  {
    "path": "frontend/src/components/ui/checkbox.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox';\nimport { Check } from 'lucide-react';\nimport * as React from 'react';\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',\n      className\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn('flex items-center justify-center text-current')}\n    >\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n));\nCheckbox.displayName = CheckboxPrimitive.Root.displayName;\n\nexport { Checkbox };\n"
  },
  {
    "path": "frontend/src/components/ui/command.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { type DialogProps } from '@radix-ui/react-dialog';\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { Search } from 'lucide-react';\nimport * as React from 'react';\n\nimport { Dialog, DialogContent } from '@/components/ui/dialog';\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',\n      className\n    )}\n    {...props}\n  />\n));\nCommand.displayName = CommandPrimitive.displayName;\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0 shadow-lg\">\n        <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    />\n  </div>\n));\n\nCommandInput.displayName = CommandPrimitive.Input.displayName;\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}\n    {...props}\n  />\n));\n\nCommandList.displayName = CommandPrimitive.List.displayName;\n\nconst CommandListScrollable = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> & {\n    maxItems?: number;\n  }\n>(({ className, children, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}\n    {...props}\n  >\n    {children}\n  </CommandPrimitive.List>\n));\n\nCommandListScrollable.displayName = 'CommandListScrollable';\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n));\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName;\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',\n      className\n    )}\n    {...props}\n  />\n));\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName;\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 h-px bg-border', className)}\n    {...props}\n  />\n));\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName;\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className\n    )}\n    {...props}\n  />\n));\n\nCommandItem.displayName = CommandPrimitive.Item.displayName;\n\nconst CommandItemAnimated = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {\n    isSelected?: boolean;\n    index?: number;\n  }\n>(({ className, isSelected, index, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    data-index={index}\n    className={cn(\n      'relative flex cursor-pointer gap-2 select-none items-center rounded-md px-2 py-2 text-sm outline-none',\n      'transition-all duration-150',\n      'hover:scale-[1.02]',\n      'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',\n      '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n      isSelected && 'bg-accent text-accent-foreground scale-[1.02]',\n      className\n    )}\n    {...props}\n  />\n));\n\nCommandItemAnimated.displayName = 'CommandItemAnimated';\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        'ml-auto text-xs tracking-widest text-muted-foreground',\n        className\n      )}\n      {...props}\n    />\n  );\n};\nCommandShortcut.displayName = 'CommandShortcut';\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandListScrollable,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandItemAnimated,\n  CommandShortcut,\n  CommandSeparator\n};\n"
  },
  {
    "path": "frontend/src/components/ui/dialog.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport * as React from 'react';\n\nconst Dialog = DialogPrimitive.Root;\n\nconst DialogTrigger = DialogPrimitive.Trigger;\n\nconst DialogPortal = DialogPrimitive.Portal;\n\nconst DialogClose = DialogPrimitive.Close;\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      'fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      className\n    )}\n    {...props}\n  />\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal container={window.cl_shadowRootElement}>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n));\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col space-y-1.5 text-center sm:text-left',\n      className\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = 'DialogHeader';\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',\n      className\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = 'DialogFooter';\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      'text-lg font-semibold leading-none tracking-tight',\n      className\n    )}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogClose,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription\n};\n"
  },
  {
    "path": "frontend/src/components/ui/dropdown-menu.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { Check, ChevronRight, Circle } from 'lucide-react';\nimport * as React from 'react';\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPortal container={window.cl_shadowRootElement}>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPortal>\n));\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-2.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      'px-2 py-1.5 text-sm font-semibold',\n      inset && 'pl-8',\n      className\n    )}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-muted', className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn('ml-auto text-xs tracking-widest opacity-60', className)}\n      {...props}\n    />\n  );\n};\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup\n};\n"
  },
  {
    "path": "frontend/src/components/ui/form.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as LabelPrimitive from '@radix-ui/react-label';\nimport { Slot } from '@radix-ui/react-slot';\nimport * as React from 'react';\nimport {\n  Controller,\n  ControllerProps,\n  FieldPath,\n  FieldValues,\n  FormProvider,\n  useFormContext\n} from 'react-hook-form';\n\nimport { Label } from '@/components/ui/label';\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n  name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue\n);\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  );\n};\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext);\n  const itemContext = React.useContext(FormItemContext);\n  const { getFieldState, formState } = useFormContext();\n\n  const fieldState = getFieldState(fieldContext.name, formState);\n\n  if (!fieldContext) {\n    throw new Error('useFormField should be used within <FormField>');\n  }\n\n  const { id } = itemContext;\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState\n  };\n};\n\ntype FormItemContextValue = {\n  id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue\n);\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn('space-y-2', className)} {...props} />\n    </FormItemContext.Provider>\n  );\n});\nFormItem.displayName = 'FormItem';\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && 'text-destructive', className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n});\nFormLabel.displayName = 'FormLabel';\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { error, formItemId, formDescriptionId, formMessageId } =\n    useFormField();\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  );\n});\nFormControl.displayName = 'FormControl';\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn('text-sm text-muted-foreground', className)}\n      {...props}\n    />\n  );\n});\nFormDescription.displayName = 'FormDescription';\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField();\n  const body = error ? String(error?.message) : children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn('text-sm font-medium text-destructive', className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n});\nFormMessage.displayName = 'FormMessage';\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField\n};\n"
  },
  {
    "path": "frontend/src/components/ui/hover-card.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as HoverCardPrimitive from '@radix-ui/react-hover-card';\nimport * as React from 'react';\n\nconst HoverCard = HoverCardPrimitive.Root;\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger;\n\nconst HoverCardContent = React.forwardRef<\n  React.ElementRef<typeof HoverCardPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (\n  <HoverCardPrimitive.Content\n    ref={ref}\n    align={align}\n    sideOffset={sideOffset}\n    className={cn(\n      'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className\n    )}\n    {...props}\n  />\n));\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName;\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent };\n"
  },
  {
    "path": "frontend/src/components/ui/input.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as React from 'react';\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    );\n  }\n);\nInput.displayName = 'Input';\n\nexport { Input };\n"
  },
  {
    "path": "frontend/src/components/ui/label.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as LabelPrimitive from '@radix-ui/react-label';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport * as React from 'react';\n\nconst labelVariants = cva(\n  'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'\n);\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n\nexport { Label };\n"
  },
  {
    "path": "frontend/src/components/ui/pagination.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';\nimport * as React from 'react';\n\nimport { ButtonProps, buttonVariants } from '@/components/ui/button';\n\nconst Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (\n  <nav\n    role=\"navigation\"\n    aria-label=\"pagination\"\n    className={cn('mx-auto flex w-full justify-center', className)}\n    {...props}\n  />\n);\nPagination.displayName = 'Pagination';\n\nconst PaginationContent = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<'ul'>\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    className={cn('flex flex-row items-center gap-1', className)}\n    {...props}\n  />\n));\nPaginationContent.displayName = 'PaginationContent';\n\nconst PaginationItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<'li'>\n>(({ className, ...props }, ref) => (\n  <li ref={ref} className={cn('', className)} {...props} />\n));\nPaginationItem.displayName = 'PaginationItem';\n\ntype PaginationLinkProps = {\n  isActive?: boolean;\n} & Pick<ButtonProps, 'size'> &\n  React.ComponentProps<'a'>;\n\nconst PaginationLink = ({\n  className,\n  isActive,\n  size = 'icon',\n  ...props\n}: PaginationLinkProps) => (\n  <a\n    aria-current={isActive ? 'page' : undefined}\n    className={cn(\n      buttonVariants({\n        variant: isActive ? 'outline' : 'ghost',\n        size\n      }),\n      className\n    )}\n    {...props}\n  />\n);\nPaginationLink.displayName = 'PaginationLink';\n\nconst PaginationPrevious = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n  <PaginationLink\n    aria-label=\"Go to previous page\"\n    size=\"default\"\n    className={cn('gap-1 pl-2.5', className)}\n    {...props}\n  >\n    <ChevronLeft className=\"h-4 w-4\" />\n    <span>Previous</span>\n  </PaginationLink>\n);\nPaginationPrevious.displayName = 'PaginationPrevious';\n\nconst PaginationNext = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n  <PaginationLink\n    aria-label=\"Go to next page\"\n    size=\"default\"\n    className={cn('gap-1 pr-2.5', className)}\n    {...props}\n  >\n    <span>Next</span>\n    <ChevronRight className=\"h-4 w-4\" />\n  </PaginationLink>\n);\nPaginationNext.displayName = 'PaginationNext';\n\nconst PaginationEllipsis = ({\n  className,\n  ...props\n}: React.ComponentProps<'span'>) => (\n  <span\n    aria-hidden\n    className={cn('flex h-9 w-9 items-center justify-center', className)}\n    {...props}\n  >\n    <MoreHorizontal className=\"h-4 w-4\" />\n    <span className=\"sr-only\">More pages</span>\n  </span>\n);\nPaginationEllipsis.displayName = 'PaginationEllipsis';\n\nexport {\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious\n};\n"
  },
  {
    "path": "frontend/src/components/ui/popover.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport * as React from 'react';\n\nconst Popover = PopoverPrimitive.Root;\n\nconst PopoverTrigger = PopoverPrimitive.Trigger;\n\nconst PopoverPortal = PopoverPrimitive.Portal;\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (\n  <PopoverPortal container={window.cl_shadowRootElement}>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        className\n      )}\n      {...props}\n    />\n  </PopoverPortal>\n));\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverPortal, PopoverContent };\n"
  },
  {
    "path": "frontend/src/components/ui/progress.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as ProgressPrimitive from '@radix-ui/react-progress';\nimport * as React from 'react';\n\nconst Progress = React.forwardRef<\n  React.ElementRef<typeof ProgressPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => (\n  <ProgressPrimitive.Root\n    ref={ref}\n    className={cn(\n      'relative h-4 w-full overflow-hidden rounded-full bg-secondary',\n      className\n    )}\n    {...props}\n  >\n    <ProgressPrimitive.Indicator\n      className=\"h-full w-full flex-1 bg-primary transition-all\"\n      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n    />\n  </ProgressPrimitive.Root>\n));\nProgress.displayName = ProgressPrimitive.Root.displayName;\n\nexport { Progress };\n"
  },
  {
    "path": "frontend/src/components/ui/radio-group.tsx",
    "content": "'use client';\n\nimport { cn } from '@/lib/utils';\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group';\nimport { CircleIcon } from 'lucide-react';\nimport * as React from 'react';\n\nfunction RadioGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {\n  return (\n    <RadioGroupPrimitive.Root\n      data-slot=\"radio-group\"\n      className={cn('grid gap-3', className)}\n      {...props}\n    />\n  );\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {\n  return (\n    <RadioGroupPrimitive.Item\n      data-slot=\"radio-group-item\"\n      className={cn(\n        'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator\n        data-slot=\"radio-group-indicator\"\n        className=\"relative flex items-center justify-center\"\n      >\n        <CircleIcon className=\"fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"
  },
  {
    "path": "frontend/src/components/ui/resizable.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { GripVertical } from 'lucide-react';\nimport * as ResizablePrimitive from 'react-resizable-panels';\n\nconst ResizablePanelGroup = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n  <ResizablePrimitive.PanelGroup\n    className={cn(\n      'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',\n      className\n    )}\n    {...props}\n  />\n);\n\nconst ResizablePanel = ResizablePrimitive.Panel;\n\nconst ResizableHandle = ({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean;\n}) => (\n  <ResizablePrimitive.PanelResizeHandle\n    className={cn(\n      'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',\n      className\n    )}\n    {...props}\n  >\n    {withHandle && (\n      <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n        <GripVertical className=\"h-2.5 w-2.5\" />\n      </div>\n    )}\n  </ResizablePrimitive.PanelResizeHandle>\n);\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle };\n"
  },
  {
    "path": "frontend/src/components/ui/scroll-area.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';\nimport * as React from 'react';\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn('relative overflow-hidden', className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n));\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = 'vertical', ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      'flex touch-none select-none transition-colors',\n      orientation === 'vertical' &&\n        'h-full w-2.5 border-l border-l-transparent p-[1px]',\n      orientation === 'horizontal' &&\n        'h-2.5 flex-col border-t border-t-transparent p-[1px]',\n      className\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n));\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "frontend/src/components/ui/select.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react';\nimport * as React from 'react';\n\nconst Select = SelectPrimitive.Root;\n\nconst SelectGroup = SelectPrimitive.Group;\n\nconst SelectValue = SelectPrimitive.Value;\n\nconst SelectPortal = SelectPrimitive.Portal;\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"ml-1 !size-5\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n));\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      'flex cursor-default items-center justify-center py-1',\n      className\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n));\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      'flex cursor-default items-center justify-center py-1',\n      className\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n));\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName;\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = 'popper', ...props }, ref) => (\n  <SelectPortal container={window.cl_shadowRootElement}>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        'relative z-50 max-h-96 min-w-[8rem] rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n        position === 'popper' &&\n          'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        className\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          'p-1',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPortal>\n));\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}\n    {...props}\n  />\n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn('-mx-1 my-1 h-px bg-muted', className)}\n    {...props}\n  />\n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectPortal,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton\n};\n"
  },
  {
    "path": "frontend/src/components/ui/separator.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as SeparatorPrimitive from '@radix-ui/react-separator';\nimport * as React from 'react';\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = 'horizontal', decorative = true, ...props },\n    ref\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        'shrink-0 bg-border',\n        orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',\n        className\n      )}\n      {...props}\n    />\n  )\n);\nSeparator.displayName = SeparatorPrimitive.Root.displayName;\n\nexport { Separator };\n"
  },
  {
    "path": "frontend/src/components/ui/sheet.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as SheetPrimitive from '@radix-ui/react-dialog';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport { X } from 'lucide-react';\nimport * as React from 'react';\n\nconst Sheet = SheetPrimitive.Root;\n\nconst SheetTrigger = SheetPrimitive.Trigger;\n\nconst SheetClose = SheetPrimitive.Close;\n\nconst SheetPortal = SheetPrimitive.Portal;\n\nconst SheetOverlay = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Overlay\n    className={cn(\n      'fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nSheetOverlay.displayName = SheetPrimitive.Overlay.displayName;\n\nconst sheetVariants = cva(\n  'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',\n  {\n    variants: {\n      side: {\n        top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n        bottom:\n          'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',\n        left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',\n        right:\n          'inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'\n      }\n    },\n    defaultVariants: {\n      side: 'right'\n    }\n  }\n);\n\ninterface SheetContentProps\n  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,\n    VariantProps<typeof sheetVariants> {}\n\nconst SheetContent = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Content>,\n  SheetContentProps\n>(({ side = 'right', className, children, ...props }, ref) => (\n  <SheetPortal container={window.cl_shadowRootElement}>\n    <SheetOverlay />\n    <SheetPrimitive.Content\n      ref={ref}\n      className={cn(sheetVariants({ side }), className)}\n      {...props}\n    >\n      {children}\n      <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </SheetPrimitive.Close>\n    </SheetPrimitive.Content>\n  </SheetPortal>\n));\nSheetContent.displayName = SheetPrimitive.Content.displayName;\n\nconst SheetHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col space-y-2 text-center sm:text-left',\n      className\n    )}\n    {...props}\n  />\n);\nSheetHeader.displayName = 'SheetHeader';\n\nconst SheetFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',\n      className\n    )}\n    {...props}\n  />\n);\nSheetFooter.displayName = 'SheetFooter';\n\nconst SheetTitle = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Title\n    ref={ref}\n    className={cn('text-lg font-semibold text-foreground', className)}\n    {...props}\n  />\n));\nSheetTitle.displayName = SheetPrimitive.Title.displayName;\n\nconst SheetDescription = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Description\n    ref={ref}\n    className={cn('text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nSheetDescription.displayName = SheetPrimitive.Description.displayName;\n\nexport {\n  Sheet,\n  SheetPortal,\n  SheetOverlay,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription\n};\n"
  },
  {
    "path": "frontend/src/components/ui/sidebar.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { Slot } from '@radix-ui/react-slot';\nimport { VariantProps, cva } from 'class-variance-authority';\nimport { PanelLeft } from 'lucide-react';\nimport * as React from 'react';\n\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { Sheet, SheetContent } from '@/components/ui/sheet';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger\n} from '@/components/ui/tooltip';\n\nimport { useIsMobile } from '@/hooks/use-mobile';\n\nconst SIDEBAR_COOKIE_NAME = 'sidebar:state';\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH = '16rem';\nconst SIDEBAR_WIDTH_MOBILE = '18rem';\nconst SIDEBAR_WIDTH_ICON = '3rem';\nconst SIDEBAR_KEYBOARD_SHORTCUT = 'b';\n\ntype SidebarContext = {\n  state: 'expanded' | 'collapsed';\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n};\n\nconst SidebarContext = React.createContext<SidebarContext | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) {\n    throw new Error('useSidebar must be used within a SidebarProvider.');\n  }\n\n  return context;\n}\n\nconst SidebarProvider = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'> & {\n    defaultOpen?: boolean;\n    open?: boolean;\n    onOpenChange?: (open: boolean) => void;\n  }\n>(\n  (\n    {\n      defaultOpen = true,\n      open: openProp,\n      onOpenChange: setOpenProp,\n      className,\n      style,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const isMobile = useIsMobile();\n    const [openMobile, setOpenMobile] = React.useState(false);\n\n    // This is the internal state of the sidebar.\n    // We use openProp and setOpenProp for control from outside the component.\n    const [_open, _setOpen] = React.useState(defaultOpen);\n    const open = openProp ?? _open;\n    const setOpen = React.useCallback(\n      (value: boolean | ((value: boolean) => boolean)) => {\n        const openState = typeof value === 'function' ? value(open) : value;\n        if (setOpenProp) {\n          setOpenProp(openState);\n        } else {\n          _setOpen(openState);\n        }\n\n        // This sets the cookie to keep the sidebar state.\n        document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n      },\n      [setOpenProp, open]\n    );\n\n    // Helper to toggle the sidebar.\n    const toggleSidebar = React.useCallback(() => {\n      return isMobile\n        ? setOpenMobile((open) => !open)\n        : setOpen((open) => !open);\n    }, [isMobile, setOpen, setOpenMobile]);\n\n    // Adds a keyboard shortcut to toggle the sidebar.\n    React.useEffect(() => {\n      const handleKeyDown = (event: KeyboardEvent) => {\n        if (\n          event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n          (event.metaKey || event.ctrlKey)\n        ) {\n          event.preventDefault();\n          toggleSidebar();\n        }\n      };\n\n      window.addEventListener('keydown', handleKeyDown);\n      return () => window.removeEventListener('keydown', handleKeyDown);\n    }, [toggleSidebar]);\n\n    // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n    // This makes it easier to style the sidebar with Tailwind classes.\n    const state = open ? 'expanded' : 'collapsed';\n\n    const contextValue = React.useMemo<SidebarContext>(\n      () => ({\n        state,\n        open,\n        setOpen,\n        isMobile,\n        openMobile,\n        setOpenMobile,\n        toggleSidebar\n      }),\n      [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n    );\n\n    return (\n      <SidebarContext.Provider value={contextValue}>\n        <TooltipProvider delayDuration={0}>\n          <div\n            style={\n              {\n                '--sidebar-width': SIDEBAR_WIDTH,\n                '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\n                ...style\n              } as React.CSSProperties\n            }\n            className={cn(\n              'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',\n              className\n            )}\n            ref={ref}\n            {...props}\n          >\n            {children}\n          </div>\n        </TooltipProvider>\n      </SidebarContext.Provider>\n    );\n  }\n);\nSidebarProvider.displayName = 'SidebarProvider';\n\nconst Sidebar = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'> & {\n    side?: 'left' | 'right';\n    variant?: 'sidebar' | 'floating' | 'inset';\n    collapsible?: 'offcanvas' | 'icon' | 'none';\n  }\n>(\n  (\n    {\n      side = 'left',\n      variant = 'sidebar',\n      collapsible = 'offcanvas',\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n    if (collapsible === 'none') {\n      return (\n        <div\n          className={cn(\n            'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',\n            className\n          )}\n          ref={ref}\n          {...props}\n        >\n          {children}\n        </div>\n      );\n    }\n\n    if (isMobile) {\n      return (\n        <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n          <SheetContent\n            data-sidebar=\"sidebar\"\n            data-mobile=\"true\"\n            className=\"w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n            style={\n              {\n                '--sidebar-width': SIDEBAR_WIDTH_MOBILE\n              } as React.CSSProperties\n            }\n            side={side}\n          >\n            <div className=\"flex h-full w-full flex-col\">{children}</div>\n          </SheetContent>\n        </Sheet>\n      );\n    }\n\n    return (\n      <div\n        ref={ref}\n        className=\"group peer hidden md:block text-sidebar-foreground\"\n        data-state={state}\n        data-collapsible={state === 'collapsed' ? collapsible : ''}\n        data-variant={variant}\n        data-side={side}\n      >\n        {/* This is what handles the sidebar gap on desktop */}\n        <div\n          className={cn(\n            'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',\n            'group-data-[collapsible=offcanvas]:w-0',\n            'group-data-[side=right]:rotate-180',\n            variant === 'floating' || variant === 'inset'\n              ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'\n              : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'\n          )}\n        />\n        <div\n          className={cn(\n            'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',\n            side === 'left'\n              ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'\n              : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',\n            // Adjust the padding for floating and inset variants.\n            variant === 'floating' || variant === 'inset'\n              ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'\n              : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',\n            className\n          )}\n          {...props}\n        >\n          <div\n            data-sidebar=\"sidebar\"\n            className=\"flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow\"\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    );\n  }\n);\nSidebar.displayName = 'Sidebar';\n\nconst SidebarTrigger = React.forwardRef<\n  React.ElementRef<typeof Button>,\n  React.ComponentProps<typeof Button>\n>(({ className, onClick, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <Button\n      ref={ref}\n      data-sidebar=\"trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn('h-7 w-7', className)}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      {...props}\n    >\n      <PanelLeft />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  );\n});\nSidebarTrigger.displayName = 'SidebarTrigger';\n\nconst SidebarRail = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<'button'>\n>(({ className, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <button\n      ref={ref}\n      data-sidebar=\"rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',\n        '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',\n        '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',\n        'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',\n        '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',\n        '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarRail.displayName = 'SidebarRail';\n\nconst SidebarInset = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'main'>\n>(({ className, ...props }, ref) => {\n  return (\n    <main\n      ref={ref}\n      className={cn(\n        'relative flex min-h-svh flex-1 flex-col bg-background',\n        'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarInset.displayName = 'SidebarInset';\n\nconst SidebarInput = React.forwardRef<\n  React.ElementRef<typeof Input>,\n  React.ComponentProps<typeof Input>\n>(({ className, ...props }, ref) => {\n  return (\n    <Input\n      ref={ref}\n      data-sidebar=\"input\"\n      className={cn(\n        'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarInput.displayName = 'SidebarInput';\n\nconst SidebarHeader = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"header\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n});\nSidebarHeader.displayName = 'SidebarHeader';\n\nconst SidebarFooter = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"footer\"\n      className={cn('flex flex-col gap-2 p-2', className)}\n      {...props}\n    />\n  );\n});\nSidebarFooter.displayName = 'SidebarFooter';\n\nconst SidebarSeparator = React.forwardRef<\n  React.ElementRef<typeof Separator>,\n  React.ComponentProps<typeof Separator>\n>(({ className, ...props }, ref) => {\n  return (\n    <Separator\n      ref={ref}\n      data-sidebar=\"separator\"\n      className={cn('mx-2 w-auto bg-sidebar-border', className)}\n      {...props}\n    />\n  );\n});\nSidebarSeparator.displayName = 'SidebarSeparator';\n\nconst SidebarContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"content\"\n      className={cn(\n        'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarContent.displayName = 'SidebarContent';\n\nconst SidebarGroup = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"group\"\n      className={cn('relative flex w-full min-w-0 flex-col p-2', className)}\n      {...props}\n    />\n  );\n});\nSidebarGroup.displayName = 'SidebarGroup';\n\nconst SidebarGroupLabel = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : 'div';\n\n  return (\n    <Comp\n      ref={ref as any}\n      data-sidebar=\"group-label\"\n      className={cn(\n        'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\n\nconst SidebarGroupAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<'button'> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      ref={ref as any}\n      data-sidebar=\"group-action\"\n      className={cn(\n        'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',\n        // Increases the hit area of the button on mobile.\n        'after:absolute after:-inset-2 after:md:hidden',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarGroupAction.displayName = 'SidebarGroupAction';\n\nconst SidebarGroupContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"group-content\"\n    className={cn('w-full text-sm', className)}\n    {...props}\n  />\n));\nSidebarGroupContent.displayName = 'SidebarGroupContent';\n\nconst SidebarMenu = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<'ul'>\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu\"\n    className={cn('flex w-full min-w-0 flex-col gap-1', className)}\n    {...props}\n  />\n));\nSidebarMenu.displayName = 'SidebarMenu';\n\nconst SidebarMenuItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<'li'>\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    data-sidebar=\"menu-item\"\n    className={cn('group/menu-item relative', className)}\n    {...props}\n  />\n));\nSidebarMenuItem.displayName = 'SidebarMenuItem';\n\nconst sidebarMenuButtonVariants = cva(\n  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',\n        outline:\n          'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]'\n      },\n      size: {\n        default: 'h-8 text-sm',\n        sm: 'h-7 text-xs',\n        lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0'\n      }\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default'\n    }\n  }\n);\n\nconst SidebarMenuButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<'button'> & {\n    asChild?: boolean;\n    isActive?: boolean;\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n  } & VariantProps<typeof sidebarMenuButtonVariants>\n>(\n  (\n    {\n      asChild = false,\n      isActive = false,\n      variant = 'default',\n      size = 'default',\n      tooltip,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const Comp = asChild ? Slot : 'button';\n    const { isMobile, state } = useSidebar();\n\n    const button = (\n      <Comp\n        ref={ref as any}\n        data-sidebar=\"menu-button\"\n        data-size={size}\n        data-active={isActive}\n        className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n        {...props}\n      />\n    );\n\n    if (!tooltip) {\n      return button;\n    }\n\n    if (typeof tooltip === 'string') {\n      tooltip = {\n        children: tooltip\n      };\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>{button}</TooltipTrigger>\n        <TooltipContent\n          side=\"right\"\n          align=\"center\"\n          hidden={state !== 'collapsed' || isMobile}\n          {...tooltip}\n        />\n      </Tooltip>\n    );\n  }\n);\nSidebarMenuButton.displayName = 'SidebarMenuButton';\n\nconst SidebarMenuAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<'button'> & {\n    asChild?: boolean;\n    showOnHover?: boolean;\n  }\n>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : 'button';\n\n  return (\n    <Comp\n      ref={ref as any}\n      data-sidebar=\"menu-action\"\n      className={cn(\n        'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',\n        // Increases the hit area of the button on mobile.\n        'after:absolute after:-inset-2 after:md:hidden',\n        'peer-data-[size=sm]/menu-button:top-1',\n        'peer-data-[size=default]/menu-button:top-1.5',\n        'peer-data-[size=lg]/menu-button:top-2.5',\n        'group-data-[collapsible=icon]:hidden',\n        showOnHover &&\n          'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarMenuAction.displayName = 'SidebarMenuAction';\n\nconst SidebarMenuBadge = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"menu-badge\"\n    className={cn(\n      'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',\n      'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',\n      'peer-data-[size=sm]/menu-button:top-1',\n      'peer-data-[size=default]/menu-button:top-1.5',\n      'peer-data-[size=lg]/menu-button:top-2.5',\n      'group-data-[collapsible=icon]:hidden',\n      className\n    )}\n    {...props}\n  />\n));\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\n\nconst SidebarMenuSkeleton = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<'div'> & {\n    showIcon?: boolean;\n  }\n>(({ className, showIcon = false, ...props }, ref) => {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  }, []);\n\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"menu-skeleton\"\n      className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 flex-1 max-w-[--skeleton-width]\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            '--skeleton-width': width\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n});\nSidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';\n\nconst SidebarMenuSub = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<'ul'>\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu-sub\"\n    className={cn(\n      'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',\n      'group-data-[collapsible=icon]:hidden',\n      className\n    )}\n    {...props}\n  />\n));\nSidebarMenuSub.displayName = 'SidebarMenuSub';\n\nconst SidebarMenuSubItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<'li'>\n>(({ ...props }, ref) => <li ref={ref} {...props} />);\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\n\nconst SidebarMenuSubButton = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentProps<'a'> & {\n    asChild?: boolean;\n    size?: 'sm' | 'md';\n    isActive?: boolean;\n  }\n>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : 'a';\n\n  return (\n    <Comp\n      ref={ref as any}\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',\n        'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',\n        size === 'sm' && 'text-xs',\n        size === 'md' && 'text-sm',\n        'group-data-[collapsible=icon]:hidden',\n        className\n      )}\n      {...props}\n    />\n  );\n});\nSidebarMenuSubButton.displayName = 'SidebarMenuSubButton';\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar\n};\n"
  },
  {
    "path": "frontend/src/components/ui/skeleton.tsx",
    "content": "import { cn } from '@/lib/utils';\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn('animate-pulse rounded-md bg-muted', className)}\n      {...props}\n    />\n  );\n}\n\nexport { Skeleton };\n"
  },
  {
    "path": "frontend/src/components/ui/slider.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as SliderPrimitive from '@radix-ui/react-slider';\nimport * as React from 'react';\n\nconst Slider = React.forwardRef<\n  React.ElementRef<typeof SliderPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <SliderPrimitive.Root\n    ref={ref}\n    className={cn(\n      'relative flex w-full touch-none select-none items-center data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <SliderPrimitive.Track className=\"relative h-2 w-full grow overflow-hidden rounded-full bg-secondary\">\n      <SliderPrimitive.Range className=\"absolute h-full bg-primary\" />\n    </SliderPrimitive.Track>\n    <SliderPrimitive.Thumb className=\"block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\" />\n  </SliderPrimitive.Root>\n));\nSlider.displayName = SliderPrimitive.Root.displayName;\n\nexport { Slider };\n"
  },
  {
    "path": "frontend/src/components/ui/sonner.tsx",
    "content": "import { Toaster as Sonner } from 'sonner';\n\nimport { useTheme } from '../ThemeProvider';\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>;\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { variant } = useTheme();\n\n  return (\n    <Sonner\n      theme={variant}\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          toast:\n            'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',\n          description: 'group-[.toast]:text-muted-foreground',\n          actionButton:\n            'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',\n          cancelButton:\n            'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'\n        }\n      }}\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "frontend/src/components/ui/switch.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as SwitchPrimitives from '@radix-ui/react-switch';\nimport * as React from 'react';\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n      className\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'\n      )}\n    />\n  </SwitchPrimitives.Root>\n));\nSwitch.displayName = SwitchPrimitives.Root.displayName;\n\nexport { Switch };\n"
  },
  {
    "path": "frontend/src/components/ui/table.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as React from 'react';\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-auto\">\n    <table\n      ref={ref}\n      className={cn('w-full caption-bottom text-sm', className)}\n      {...props}\n    />\n  </div>\n));\nTable.displayName = 'Table';\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />\n));\nTableHeader.displayName = 'TableHeader';\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn('[&_tr:last-child]:border-0', className)}\n    {...props}\n  />\n));\nTableBody.displayName = 'TableBody';\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',\n      className\n    )}\n    {...props}\n  />\n));\nTableFooter.displayName = 'TableFooter';\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',\n      className\n    )}\n    {...props}\n  />\n));\nTableRow.displayName = 'TableRow';\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',\n      className\n    )}\n    {...props}\n  />\n));\nTableHead.displayName = 'TableHead';\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}\n    {...props}\n  />\n));\nTableCell.displayName = 'TableCell';\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn('mt-4 text-sm text-muted-foreground', className)}\n    {...props}\n  />\n));\nTableCaption.displayName = 'TableCaption';\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption\n};\n"
  },
  {
    "path": "frontend/src/components/ui/tabs.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport * as React from 'react';\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n      className\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n      className\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n      className\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsContent, TabsList, TabsTrigger };\n"
  },
  {
    "path": "frontend/src/components/ui/textarea.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as React from 'react';\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaElement,\n  React.ComponentProps<'textarea'>\n>(({ className, ...props }, ref) => {\n  return (\n    <textarea\n      className={cn(\n        'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      ref={ref}\n      {...props}\n    />\n  );\n});\nTextarea.displayName = 'Textarea';\n\nexport { Textarea };\n"
  },
  {
    "path": "frontend/src/components/ui/tooltip.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\nimport * as React from 'react';\n\n// Centralized tooltip timing\nexport const TOOLTIP_DELAY_MS = 500;\nexport const TOOLTIP_SKIP_DELAY_MS = 0;\n\ntype ProviderProps = React.ComponentProps<typeof TooltipPrimitive.Provider>;\n\nconst TooltipProvider = ({\n  delayDuration = TOOLTIP_DELAY_MS,\n  skipDelayDuration = TOOLTIP_SKIP_DELAY_MS,\n  ...props\n}: ProviderProps) => (\n  <TooltipPrimitive.Provider\n    delayDuration={delayDuration}\n    skipDelayDuration={skipDelayDuration}\n    {...props}\n  />\n);\n\nconst Tooltip = TooltipPrimitive.Root;\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Content\n    ref={ref}\n    sideOffset={sideOffset}\n    className={cn(\n      'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n      className\n    )}\n    {...props}\n  />\n));\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n"
  },
  {
    "path": "frontend/src/contexts/MessageContext.tsx",
    "content": "import { createContext } from 'react';\n\nimport { IMessageContext } from 'types/messageContext';\n\nconst defaultMessageContext = {\n  highlightedMessage: null,\n  loading: false,\n  editable: false,\n  onElementRefClick: undefined,\n  onFeedbackUpdated: undefined,\n  showFeedbackButtons: true,\n  onError: () => undefined,\n  uiName: '',\n  cot: 'hidden' as const\n};\n\nconst MessageContext = createContext<IMessageContext>(defaultMessageContext);\n\nexport { MessageContext, defaultMessageContext };\n"
  },
  {
    "path": "frontend/src/hooks/query.ts",
    "content": "import { useMemo } from 'react';\nimport { useLocation } from 'react-router-dom';\n\nexport function useQuery() {\n  const { search } = useLocation();\n\n  return useMemo(() => new URLSearchParams(search), [search]);\n}\n"
  },
  {
    "path": "frontend/src/hooks/use-mobile.tsx",
    "content": "import * as React from 'react';\n\nconst MOBILE_BREAKPOINT = 768;\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(\n    undefined\n  );\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    };\n    mql.addEventListener('change', onChange);\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    return () => mql.removeEventListener('change', onChange);\n  }, []);\n\n  return !!isMobile;\n}\n"
  },
  {
    "path": "frontend/src/hooks/useCommandNavigation.tsx",
    "content": "import { useCallback, useEffect, useState } from 'react';\n\ninterface UseCommandNavigationProps {\n  items: any[];\n  isOpen: boolean;\n  onSelect: (item: any) => void;\n  onClose?: () => void;\n}\n\nexport const useCommandNavigation = ({\n  items,\n  isOpen,\n  onSelect,\n  onClose\n}: UseCommandNavigationProps) => {\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [lastMouseMove, setLastMouseMove] = useState(0);\n\n  // Reset selection when opening or items change\n  useEffect(() => {\n    if (isOpen) {\n      setSelectedIndex(0);\n      setLastMouseMove(0);\n    }\n  }, [isOpen, items.length]);\n\n  const handleMouseMove = useCallback(\n    (index: number) => {\n      const now = Date.now();\n      // Only update if mouse actually moved (not just from render)\n      if (now - lastMouseMove > 50) {\n        setSelectedIndex(index);\n        setLastMouseMove(now);\n      }\n    },\n    [lastMouseMove]\n  );\n\n  const handleMouseLeave = useCallback(() => {\n    // Keep the last hovered item selected when mouse leaves\n    setLastMouseMove(Date.now());\n  }, []);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (!isOpen || items.length === 0) return;\n\n      // Check if mouse was recently moved\n      const timeSinceMouseMove = Date.now() - lastMouseMove;\n      const isUsingKeyboard = timeSinceMouseMove > 100;\n\n      switch (e.key) {\n        case 'ArrowDown':\n          e.preventDefault();\n          e.stopPropagation();\n          if (isUsingKeyboard) {\n            setSelectedIndex((prev) =>\n              prev < items.length - 1 ? prev + 1 : 0\n            );\n          }\n          break;\n\n        case 'ArrowUp':\n          e.preventDefault();\n          e.stopPropagation();\n          if (isUsingKeyboard) {\n            setSelectedIndex((prev) =>\n              prev > 0 ? prev - 1 : items.length - 1\n            );\n          }\n          break;\n\n        case 'Enter': {\n          e.preventDefault();\n          e.stopPropagation();\n          const selectedItem = items[selectedIndex];\n          if (selectedItem) {\n            onSelect(selectedItem);\n          }\n          break;\n        }\n\n        case 'Tab': {\n          e.preventDefault();\n          e.stopPropagation();\n          const selectedItem = items[selectedIndex];\n          if (selectedItem) {\n            onSelect(selectedItem);\n          }\n          break;\n        }\n\n        case 'Escape':\n          e.preventDefault();\n          e.stopPropagation();\n          if (onClose) {\n            onClose();\n          }\n          break;\n      }\n    },\n    [isOpen, items, selectedIndex, lastMouseMove, onSelect, onClose]\n  );\n\n  return {\n    selectedIndex,\n    handleMouseMove,\n    handleMouseLeave,\n    handleKeyDown\n  };\n};\n"
  },
  {
    "path": "frontend/src/hooks/useFetch.tsx",
    "content": "import { useContext } from 'react';\nimport useSWR, { SWRResponse } from 'swr';\n\nimport { ChainlitContext } from '@chainlit/react-client';\n\nconst fetcher =\n  (isChainlitRequest: boolean) =>\n  async (url: string): Promise<any> => {\n    const fetchOptions: RequestInit = {\n      ...(isChainlitRequest && { credentials: 'include' })\n    };\n\n    const response = await fetch(url, fetchOptions);\n\n    if (!response.ok) {\n      throw new Error('Network response was not ok');\n    }\n\n    const contentType = response.headers.get('content-type');\n    return contentType?.includes('application/json')\n      ? response.json()\n      : response.text();\n  };\n\nconst useFetch = (endpoint: string | null): SWRResponse<any, Error> => {\n  const apiClient = useContext(ChainlitContext);\n  const isChainlitRequest = endpoint?.startsWith(apiClient.httpEndpoint);\n\n  return useSWR<any, Error>(endpoint, fetcher(!!isChainlitRequest));\n};\n\nexport { useFetch };\n"
  },
  {
    "path": "frontend/src/hooks/useLayoutMaxWidth.tsx",
    "content": "import { useConfig } from '@chainlit/react-client';\n\nconst useLayoutMaxWidth = () => {\n  const { config } = useConfig();\n  return config?.ui.layout === 'wide'\n    ? 'min(60rem, 100vw)'\n    : 'min(48rem, 100vw)';\n};\n\nexport { useLayoutMaxWidth };\n"
  },
  {
    "path": "frontend/src/hooks/usePlatform.ts",
    "content": "import { useMemo } from 'react';\n\nconst MOBILE_REGEX =\n  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;\n\ntype PlatformPayload = {\n  isSSR: boolean;\n  isMobile: boolean;\n  isDesktop: boolean;\n  isMac: boolean;\n};\n\nexport const usePlatform = (): PlatformPayload => {\n  const platforms = useMemo(() => {\n    if (navigator == null) {\n      return {\n        isSSR: true,\n        isMobile: false,\n        isDesktop: false,\n        isMac: false\n      };\n    }\n    const isMobile = navigator.userAgent.match(MOBILE_REGEX) != null;\n    const isMac = navigator.userAgent.toUpperCase().match(/MAC/) != null;\n\n    return {\n      isSSR: false,\n      isMobile,\n      isDesktop: !isMobile,\n      isMac\n    };\n  }, []);\n\n  return platforms;\n};\n"
  },
  {
    "path": "frontend/src/hooks/useUpload.tsx",
    "content": "import { useCallback } from 'react';\nimport {\n  DropzoneOptions,\n  FileRejection,\n  FileWithPath,\n  useDropzone\n} from 'react-dropzone';\n\nimport type { FileSpec } from 'client-types/';\n\ninterface useUploadProps {\n  onError?: (error: string) => void;\n  onResolved: (payloads: FileWithPath[]) => void;\n  options?: DropzoneOptions;\n  spec: FileSpec;\n}\n\nconst useUpload = ({ onError, onResolved, options, spec }: useUploadProps) => {\n  const onDrop: DropzoneOptions['onDrop'] = useCallback(\n    (acceptedFiles: FileWithPath[], fileRejections: FileRejection[]) => {\n      if (fileRejections.length > 0) {\n        if (fileRejections[0].errors[0].code === 'file-too-large') {\n          onError?.(`File is larger than ${spec.max_size_mb} MB`);\n        } else {\n          onError?.(fileRejections[0].errors[0].message);\n        }\n      }\n\n      if (!acceptedFiles.length) return;\n      return onResolved(acceptedFiles);\n    },\n    [spec]\n  );\n\n  let dzAccept: Record<string, string[]> = {};\n  const accept = spec.accept;\n\n  if (Array.isArray(accept)) {\n    accept.forEach((a) => {\n      if (typeof a === 'string') {\n        dzAccept[a] = [];\n      }\n    });\n  } else if (typeof accept === 'object') {\n    dzAccept = accept;\n  }\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop,\n    maxFiles: spec.max_files || undefined,\n    accept: dzAccept,\n    maxSize: (spec.max_size_mb || 2) * 1000000,\n    ...options\n  });\n\n  return { getInputProps, getRootProps, isDragActive };\n};\n\nexport { useUpload };\n"
  },
  {
    "path": "frontend/src/i18n/dateLocale.ts",
    "content": "import { Locale } from 'date-fns';\nimport { bn } from 'date-fns/locale/bn';\nimport { de } from 'date-fns/locale/de';\nimport { el } from 'date-fns/locale/el';\nimport { enUS } from 'date-fns/locale/en-US';\nimport { es } from 'date-fns/locale/es';\nimport { fr } from 'date-fns/locale/fr';\nimport { gu } from 'date-fns/locale/gu';\nimport { he } from 'date-fns/locale/he';\nimport { hi } from 'date-fns/locale/hi';\nimport { it } from 'date-fns/locale/it';\nimport { ja } from 'date-fns/locale/ja';\nimport { kn } from 'date-fns/locale/kn';\nimport { ko } from 'date-fns/locale/ko';\nimport { nl } from 'date-fns/locale/nl';\nimport { ta } from 'date-fns/locale/ta';\nimport { te } from 'date-fns/locale/te';\nimport { zhCN } from 'date-fns/locale/zh-CN';\nimport { zhTW } from 'date-fns/locale/zh-TW';\n\n/**\n * Mapping from i18n locale codes (used in the app) to date-fns locale objects.\n * This ensures date formatting aligns with the application's language settings.\n *\n * Locale codes match the translation files in backend/chainlit/translations:\n * - bn.json, de-DE.json, el-GR.json, en-US.json, es.json, fr-FR.json, gu.json,\n * - he-IL.json, hi.json, it.json, ja.json, kn.json, ko.json, ml.json, mr.json,\n * - nl.json, ta.json, te.json, zh-CN.json, zh-TW.json\n */\nconst localeMap: Record<string, Locale> = {\n  // Bengali (bn.json)\n  bn: bn,\n\n  // German (de-DE.json)\n  'de-DE': de,\n  de: de,\n\n  // Greek (el-GR.json)\n  'el-GR': el,\n  el: el,\n\n  // English (en-US.json)\n  'en-US': enUS,\n  en: enUS,\n\n  // Spanish (es.json)\n  es: es,\n\n  // French (fr-FR.json)\n  'fr-FR': fr,\n  fr: fr,\n\n  // Gujarati (gu.json)\n  gu: gu,\n\n  // Hebrew (he-IL.json)\n  'he-IL': he,\n  he: he,\n\n  // Hindi (hi.json)\n  hi: hi,\n\n  // Italian (it.json)\n  it: it,\n\n  // Japanese (ja.json)\n  ja: ja,\n\n  // Korean (ko.json)\n  ko: ko,\n\n  // Kannada (kn.json)\n  kn: kn,\n\n  // Dutch (nl.json)\n  nl: nl,\n\n  // Tamil (ta.json)\n  ta: ta,\n\n  // Telugu (te.json)\n  te: te,\n\n  // Chinese Simplified (zh-CN.json)\n  'zh-CN': zhCN,\n\n  // Chinese Traditional (zh-TW.json)\n  'zh-TW': zhTW\n\n  // Note: ml (Malayalam) and mr (Marathi) are not available in date-fns\n  // These will fall back to English (enUS)\n};\n\nexport function getDateFnsLocale(i18nLocale: string | undefined): Locale {\n  if (!i18nLocale) {\n    return enUS;\n  }\n\n  // Try exact match first\n  if (localeMap[i18nLocale]) {\n    return localeMap[i18nLocale];\n  }\n\n  // Try to match base language (e.g., 'en' from 'en-GB')\n  const baseLocale = i18nLocale.split('-')[0];\n  if (localeMap[baseLocale]) {\n    return localeMap[baseLocale];\n  }\n\n  // Fallback to English\n  return enUS;\n}\n"
  },
  {
    "path": "frontend/src/i18n/index.ts",
    "content": "import i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\n\nconst i18nConfig = {\n  fallbackLng: 'en-US',\n  defaultNS: 'translation'\n};\n\nexport function i18nSetupLocalization(): void {\n  i18n\n    .use(initReactI18next)\n    .init(i18nConfig)\n    .catch((err) => console.error('[i18n] Failed to setup localization.', err));\n}\n"
  },
  {
    "path": "frontend/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  margin: 0;\n  padding: 0;\n  font-family: var(--font-sans);\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n  font-family: var(--font-mono);\n}\n\n@layer base {\n  :root {\n    --font-sans: 'Inter', sans-serif;\n    --font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n      monospace;\n    --background: 0 0% 100%;\n    --foreground: 0 0% 5%;\n    --card: 0 0% 100%;\n    --card-foreground: 0 0% 5%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 0 0% 5%;\n    --primary: 340 92% 52%;\n    --primary-foreground: 0 0% 100%;\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n    --muted: 0 0% 90%;\n    --muted-foreground: 0 0% 36%;\n    --accent: 0 0% 95%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 0 0% 90%;\n    --input: 0 0% 90%;\n    --ring: 340 92% 52%;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n    --radius: 0.75rem;\n    --sidebar-background: 0 0% 98%;\n    --sidebar-foreground: 240 5.3% 26.1%;\n    --sidebar-primary: 240 5.9% 10%;\n    --sidebar-primary-foreground: 0 0% 98%;\n    --sidebar-accent: 240 4.8% 95.9%;\n    --sidebar-accent-foreground: 240 5.9% 10%;\n    --sidebar-border: 220 13% 91%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n  .dark {\n    --background: 0 0% 13%;\n    --foreground: 0 0% 93%;\n    --card: 0 0% 18%;\n    --card-foreground: 210 40% 98%;\n    --popover: 0 0% 18%;\n    --popover-foreground: 210 40% 98%;\n    --primary: 340 92% 52%;\n    --primary-foreground: 0 0% 100%;\n    --secondary: 0 0% 19%;\n    --secondary-foreground: 210 40% 98%;\n    --muted: 0 1% 26%;\n    --muted-foreground: 0 0% 71%;\n    --accent: 0 0% 26%;\n    --accent-foreground: 210 40% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 0 1% 26%;\n    --input: 0 1% 26%;\n    --ring: 340 92% 52%;\n    --chart-1: 220 70% 50%;\n    --chart-2: 160 60% 45%;\n    --chart-3: 30 80% 55%;\n    --chart-4: 280 65% 60%;\n    --chart-5: 340 75% 55%;\n    --sidebar-background: 0 0% 9%;\n    --sidebar-foreground: 240 4.8% 95.9%;\n    --sidebar-primary: 224.3 76.3% 48%;\n    --sidebar-primary-foreground: 0 0% 100%;\n    --sidebar-accent: 0 0% 13%;\n    --sidebar-accent-foreground: 240 4.8% 95.9%;\n    --sidebar-border: 240 3.7% 15.9%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n@keyframes loading-shimmer {\n  0% {\n    background-position: -100% top;\n  }\n\n  to {\n    background-position: 250% top;\n  }\n}\n\n.loading-shimmer {\n  background-position: -100% top;\n  text-fill-color: transparent;\n  -webkit-text-fill-color: transparent;\n  animation-delay: 0s;\n  animation-duration: 4s;\n  animation-iteration-count: infinite;\n  animation-name: loading-shimmer;\n  background: hsl(var(--muted))\n    gradient(\n      linear,\n      100% 0,\n      0 0,\n      from(hsl(var(--muted))),\n      color-stop(0.5, hsl(var(--foreground))),\n      to(hsl(var(--muted)))\n    );\n  background: hsl(var(--muted)) -webkit-gradient(linear, 100% 0, 0 0, from(hsl(var(--muted))), color-stop(0.5, hsl(var(--foreground))), to(hsl(var(--muted))));\n  background-clip: text;\n  -webkit-background-clip: text;\n  background-repeat: no-repeat;\n  background-size: 50% 200%;\n}\n\n/* Custom scrollbar styles for command lists */\n@layer utilities {\n  .custom-scrollbar::-webkit-scrollbar {\n    width: 4px;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  .custom-scrollbar::-webkit-scrollbar-thumb {\n    background-color: hsl(var(--muted-foreground) / 0.3);\n    border-radius: 2px;\n  }\n\n  .custom-scrollbar {\n    scrollbar-width: thin;\n    scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;\n  }\n}\n\n/* Task highlight animation */\n\n\n.task-highlight {\n  animation: task-highlight-blink 0.6s linear 0s 2;\n  background-color: hsl(var(--background));\n}\n\n@keyframes task-highlight-blink {\n  0% {\n    background-color: hsl(var(--foreground));\n  }\n  25% {\n    background-color: hsl(var(--foreground));\n  }\n  50% {\n    background-color: transparent;\n  }\n  75% {\n    background-color: hsl(var(--foreground));\n  }\n  100% {\n    background-color: transparent;\n  }\n}"
  },
  {
    "path": "frontend/src/index.d.ts",
    "content": "export {};\n\n/**\n * Enables using the findLast method on arrays.\n */\ndeclare global {\n  interface Array<T> {\n    findLast(\n      predicate: (value: T, index: number, array: T[]) => unknown,\n      thisArg?: any\n    ): T | undefined;\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/message.ts",
    "content": "import type { IMessageElement } from 'client-types/';\n\nconst toSafeLinkTarget = (name: string) =>\n  encodeURIComponent(name.replace(/\\s+/g, '_'))\n    .replace(/\\(/g, '%28')\n    .replace(/\\)/g, '%29'); // Encode parentheses to avoid issues in URLs\n\nconst isForIdMatch = (id: string | number | undefined, forId: string) => {\n  if (!forId || !id) {\n    return false;\n  }\n\n  return forId === id.toString();\n};\n\nconst escapeRegExp = (string: string) => {\n  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping\n  return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n};\n\nexport const prepareContent = ({\n  elements,\n  content,\n  id,\n  language\n}: {\n  elements: IMessageElement[];\n  content?: string;\n  id: string;\n  language?: string;\n}) => {\n  const elementNames = elements.map((e) => escapeRegExp(e.name));\n\n  // Sort by descending length to avoid matching substrings\n  elementNames.sort((a, b) => b.length - a.length);\n\n  const elementRegexp = elementNames.length\n    ? new RegExp(`(${elementNames.join('|')})`, 'g')\n    : undefined;\n\n  let preparedContent = content ? content.trim() : '';\n  const inlinedElements = elements.filter(\n    (e) => isForIdMatch(id, e?.forId) && e.display === 'inline'\n  );\n  const refElements: IMessageElement[] = [];\n\n  if (elementRegexp) {\n    preparedContent = preparedContent.replaceAll(elementRegexp, (match) => {\n      const element = elements.find((e) => {\n        const nameMatch = e.name === match;\n        const scopeMatch = isForIdMatch(id, e?.forId);\n        return nameMatch && scopeMatch;\n      });\n      const foundElement = !!element;\n\n      const inlined = element?.display === 'inline';\n      if (!foundElement) {\n        // Element reference does not exist, return plain text\n        return match;\n      } else if (inlined) {\n        // If element is inlined, add it to the list and return plain text\n        if (inlinedElements.indexOf(element) === -1) {\n          inlinedElements.push(element);\n        }\n        return match;\n      } else {\n        // Element is a reference, add it to the list and return link\n        refElements.push(element);\n        // Build a Markdown-safe link: escape text, and encode () in the slug\n        // The address in the link is not used anyway\n        return `[${match}](${toSafeLinkTarget(match)})`;\n      }\n    });\n  }\n\n  if (language && preparedContent) {\n    const prefix = `\\`\\`\\`${language}`;\n    const suffix = '```';\n    if (!preparedContent.startsWith('```')) {\n      preparedContent = `${prefix}\\n${preparedContent}\\n${suffix}`;\n    }\n  }\n  return {\n    preparedContent,\n    inlinedElements,\n    refElements\n  };\n};\n"
  },
  {
    "path": "frontend/src/lib/router.ts",
    "content": "const getRouterBasename = () => {\n  const ogTitleMeta = document.querySelector('meta[property=\"og:root_path\"]');\n  if (ogTitleMeta && typeof ogTitleMeta.getAttribute('content') === 'string') {\n    return ogTitleMeta.getAttribute('content')!;\n  } else {\n    return '';\n  }\n};\n\nexport default getRouterBasename;\n"
  },
  {
    "path": "frontend/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nimport { IStep } from '@chainlit/react-client';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport const hasMessage = (messages: IStep[]): boolean => {\n  const validTypes = ['user_message', 'assistant_message', 'tool'];\n  return messages.some(\n    (message) =>\n      validTypes.includes(message.type) || hasMessage(message.steps || [])\n  );\n};\n\nexport function hslToHex(hslStr: string): string {\n  // Parse HSL string\n  const values = hslStr\n    .split(' ')\n    .map((value) => parseFloat(value.replace('%', '')));\n\n  const h = values[0];\n  const s = values[1];\n  const l = values[2];\n\n  // Convert to fractions of 1\n  const hue = h / 360;\n  const sat = s / 100;\n  const light = l / 100;\n\n  function hueToRgb(p: number, q: number, t: number): number {\n    if (t < 0) t += 1;\n    if (t > 1) t -= 1;\n    if (t < 1 / 6) return p + (q - p) * 6 * t;\n    if (t < 1 / 2) return q;\n    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n    return p;\n  }\n\n  let r, g, b;\n\n  if (sat === 0) {\n    r = g = b = light;\n  } else {\n    const q = light < 0.5 ? light * (1 + sat) : light + sat - light * sat;\n    const p = 2 * light - q;\n\n    r = hueToRgb(p, q, hue + 1 / 3);\n    g = hueToRgb(p, q, hue);\n    b = hueToRgb(p, q, hue - 1 / 3);\n  }\n\n  const toHex = (x: number): string => {\n    const hex = Math.round(x * 255).toString(16);\n    return hex.length === 1 ? '0' + hex : hex;\n  };\n\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n"
  },
  {
    "path": "frontend/src/main.tsx",
    "content": "import AppWrapper from 'AppWrapper';\nimport { apiClient } from 'api';\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { RecoilRoot } from 'recoil';\n\nimport { ChainlitContext } from '@chainlit/react-client';\n\nimport './index.css';\n\nimport { i18nSetupLocalization } from './i18n';\n\ni18nSetupLocalization();\n\nReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(\n  <React.StrictMode>\n    <ChainlitContext.Provider value={apiClient}>\n      <RecoilRoot>\n        <AppWrapper />\n      </RecoilRoot>\n    </ChainlitContext.Provider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "frontend/src/pages/AuthCallback.tsx",
    "content": "import { useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { useAuth } from '@chainlit/react-client';\n\nexport default function AuthCallback() {\n  const { user, setUserFromAPI } = useAuth();\n  const navigate = useNavigate();\n\n  // Fetch user in cookie-based oauth.\n  useEffect(() => {\n    if (!user) setUserFromAPI();\n  }, []);\n\n  useEffect(() => {\n    if (user) {\n      navigate('/');\n    }\n  }, [user]);\n\n  return null;\n}\n"
  },
  {
    "path": "frontend/src/pages/Element.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\n\nimport Page from 'pages/Page';\n\nimport {\n  IMessageElement,\n  useApi,\n  useChatData,\n  useConfig\n} from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { ElementView } from '@/components/ElementView';\nimport { Loader } from '@/components/Loader';\n\nimport { useQuery } from 'hooks/query';\n\nexport default function Element() {\n  const { id } = useParams();\n  const query = useQuery();\n  const { elements } = useChatData();\n  const { config } = useConfig();\n\n  const [element, setElement] = useState<IMessageElement | null>(null);\n  const navigate = useNavigate();\n\n  const threadId = query.get('thread');\n\n  const dataPersistence = config?.dataPersistence;\n\n  const { data, isLoading, error } = useApi<IMessageElement>(\n    id && threadId && dataPersistence\n      ? `/project/thread/${threadId}/element/${id}`\n      : null\n  );\n\n  useEffect(() => {\n    if (data) {\n      setElement(data);\n    } else if (id && !dataPersistence && !element) {\n      const foundElement = elements.find((element) => element.id === id);\n\n      if (foundElement) {\n        setElement(foundElement);\n      }\n    }\n  }, [data, element, elements, id, threadId]);\n\n  return (\n    <Page>\n      <>\n        {isLoading ? (\n          <div className=\"flex flex-grow justify-center items-center\">\n            <Loader className=\"!size-6\" />\n          </div>\n        ) : null}\n        {error ? <Alert variant=\"error\">{error.message}</Alert> : null}\n        {element ? (\n          <ElementView element={element} onGoBack={() => navigate('/')} />\n        ) : null}\n      </>\n    </Page>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/Env.tsx",
    "content": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useForm } from 'react-hook-form';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilState } from 'recoil';\nimport { toast } from 'sonner';\nimport { z } from 'zod';\n\nimport { useConfig } from '@chainlit/react-client';\n\nimport Alert from '@/components/Alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\n\nimport { useLayoutMaxWidth } from '@/hooks/useLayoutMaxWidth';\n\nimport { userEnvState } from '@/state/user';\n\nconst Env = () => {\n  const navigate = useNavigate();\n  const { config } = useConfig();\n  const [userEnv, setUserEnv] = useRecoilState(userEnvState);\n  const layoutMaxWidth = useLayoutMaxWidth();\n  const { t } = useTranslation();\n  const requiredKeys = config?.userEnv || [];\n\n  // Create initial values object\n  const initialValues: Record<string, string> = {};\n  requiredKeys.forEach((key) => {\n    initialValues[key] = userEnv[key] || '';\n  });\n\n  // Create dynamic Zod schema based on required keys\n  const schemaObject: Record<string, z.ZodString> = {};\n  requiredKeys.forEach((key) => {\n    schemaObject[key] = z.string().min(1, { message: 'Required' });\n  });\n  const schema = z.object(schemaObject);\n\n  type FormValues = z.infer<typeof schema>;\n\n  const {\n    register,\n    handleSubmit,\n    formState: { errors, touchedFields }\n  } = useForm<FormValues>({\n    defaultValues: initialValues,\n    resolver: zodResolver(schema),\n    mode: 'onBlur'\n  });\n\n  const onSubmit = async (values: FormValues) => {\n    localStorage.setItem('userEnv', JSON.stringify(values));\n    setUserEnv(values);\n    toast.success(t('apiKeys.success.saved'));\n    navigate('/');\n  };\n\n  if (requiredKeys.length === 0) {\n    navigate('/');\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-col flex-grow\">\n      <div\n        className=\"flex flex-col flex-grow gap-4 mx-auto w-full\"\n        style={{ maxWidth: layoutMaxWidth }}\n      >\n        <Card className=\"mt-8\">\n          <CardHeader>\n            <CardTitle className=\"text-lg font-bold\">\n              {t('apiKeys.title')}\n            </CardTitle>\n          </CardHeader>\n          <CardContent>\n            <Alert variant=\"info\" className=\"mb-6\">\n              {t('apiKeys.description')}\n            </Alert>\n\n            <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-4\">\n              {requiredKeys.map((key) => (\n                <div key={key} className=\"space-y-2\">\n                  <Label htmlFor={key}>{key}</Label>\n                  <Input\n                    id={key}\n                    type={config?.maskUserEnv !== false ? \"password\" : \"text\"}\n                    {...register(key)}\n                    className={\n                      touchedFields[key] && errors[key] ? 'border-red-500' : ''\n                    }\n                  />\n                  {touchedFields[key] && errors[key] && (\n                    <p className=\"text-sm text-red-500\">\n                      {errors[key]?.message}\n                    </p>\n                  )}\n                </div>\n              ))}\n\n              <Button id=\"submit-env\" type=\"submit\" className=\"w-full mt-4\">\n                Save\n              </Button>\n            </form>\n          </CardContent>\n        </Card>\n      </div>\n    </div>\n  );\n};\n\nexport default Env;\n"
  },
  {
    "path": "frontend/src/pages/Home.tsx",
    "content": "import Page from 'pages/Page';\n\nimport Chat from '@/components/chat';\n\nexport default function Home() {\n  return (\n    <Page>\n      <Chat />\n    </Page>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/Login.tsx",
    "content": "import { useContext, useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { LoginForm } from '@/components/LoginForm';\nimport { Logo } from '@/components/Logo';\nimport { useTheme } from '@/components/ThemeProvider';\n\nimport { useQuery } from 'hooks/query';\n\nimport { ChainlitContext, useAuth } from 'client-types/*';\n\nexport const LoginError = new Error(\n  'Error logging in. Please try again later.'\n);\n\nexport default function Login() {\n  const query = useQuery();\n  const { data: config, user, setUserFromAPI } = useAuth();\n  const [error, setError] = useState('');\n  const apiClient = useContext(ChainlitContext);\n  const navigate = useNavigate();\n  const { variant } = useTheme();\n  const isDarkMode = variant === 'dark';\n\n  const handleCookieAuth = (json: any): void => {\n    if (json?.success != true) throw LoginError;\n\n    // Validate login cookie and get user data.\n    setUserFromAPI();\n  };\n\n  const handleAuth = async (\n    jsonPromise: Promise<any>,\n    redirectURL?: string\n  ) => {\n    try {\n      const json = await jsonPromise;\n\n      handleCookieAuth(json);\n\n      if (redirectURL) {\n        navigate(redirectURL);\n      }\n    } catch (error: any) {\n      setError(error.message);\n    }\n  };\n\n  const handleHeaderAuth = async () => {\n    const jsonPromise = apiClient.headerAuth();\n\n    // Why does apiClient redirect to '/' but handlePasswordLogin to callbackUrl?\n    await handleAuth(jsonPromise, '/');\n  };\n\n  const handlePasswordLogin = async (email: string, password: string) => {\n    const formData = new FormData();\n    formData.append('username', email);\n    formData.append('password', password);\n\n    const jsonPromise = apiClient.passwordAuth(formData);\n    await handleAuth(jsonPromise);\n  };\n\n  useEffect(() => {\n    setError(query.get('error') || '');\n  }, [query]);\n\n  useEffect(() => {\n    if (!config) {\n      return;\n    }\n    if (!config.requireLogin) {\n      navigate('/');\n    }\n    if (config.headerAuth && !user) {\n      handleHeaderAuth();\n    }\n    if (user) {\n      navigate('/');\n    }\n  }, [config, user]);\n\n  return (\n    <div className=\"grid min-h-svh lg:grid-cols-2\">\n      <div className=\"flex flex-col gap-4 p-6 md:p-10\">\n        <div className=\"flex justify-center gap-2 md:justify-start\">\n          <Logo className=\"w-[150px]\" />\n        </div>\n        <div className=\"flex flex-1 items-center justify-center\">\n          <div className=\"w-full max-w-xs\">\n            <LoginForm\n              error={error}\n              callbackUrl=\"/\"\n              providers={config?.oauthProviders || []}\n              onPasswordSignIn={\n                config?.passwordAuth ? handlePasswordLogin : undefined\n              }\n              onOAuthSignIn={async (provider: string) => {\n                window.location.href = apiClient.getOAuthEndpoint(provider);\n              }}\n            />\n          </div>\n        </div>\n      </div>\n      {!config?.headerAuth ? (\n        <div className=\"relative hidden bg-muted lg:block overflow-hidden\">\n          <img\n            src={\n              config?.ui?.login_page_image ||\n              apiClient.buildEndpoint('/favicon')\n            }\n            alt=\"Image\"\n            className={`absolute inset-0 h-full w-full object-cover ${\n              isDarkMode\n                ? config?.ui?.login_page_image_dark_filter ||\n                  'brightness-[0.2] grayscale'\n                : config?.ui?.login_page_image_filter || ''\n            }`}\n          />\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/Page.tsx",
    "content": "import { Navigate } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\n\nimport { sideViewState, useAuth, useConfig } from '@chainlit/react-client';\n\nimport ChatSettingsSidebar from '@/components/ChatSettings/ChatSettingsSidebar';\nimport ElementSideView from '@/components/ElementSideView';\nimport LeftSidebar from '@/components/LeftSidebar';\nimport { TaskList } from '@/components/Tasklist';\nimport { Header } from '@/components/header';\nimport { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';\nimport { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';\n\nimport { userEnvState } from 'state/user';\n\ntype Props = {\n  children: JSX.Element;\n};\n\nconst Page = ({ children }: Props) => {\n  const { config } = useConfig();\n  const { data } = useAuth();\n  const userEnv = useRecoilValue(userEnvState);\n  const sideView = useRecoilValue(sideViewState);\n\n  if (config?.userEnv) {\n    for (const key of config.userEnv || []) {\n      if (!userEnv[key]) return <Navigate to=\"/env\" />;\n    }\n  }\n\n  const showSettingsSidebar = config?.ui?.chat_settings_location === 'sidebar';\n\n  const mainContent = (\n    <div className=\"flex flex-col h-full w-full\">\n      <Header />\n      <ResizablePanelGroup\n        direction=\"horizontal\"\n        className=\"flex flex-row flex-grow\"\n      >\n        <ResizablePanel\n          className=\"flex flex-col h-full w-full\"\n          minSize={40}\n          defaultSize={60}\n        >\n          <div className=\"flex flex-row flex-grow overflow-auto\">\n            {children}\n          </div>\n        </ResizablePanel>\n        {sideView ? <ElementSideView /> : <TaskList isMobile={false} />}\n        {showSettingsSidebar && <ChatSettingsSidebar />}\n      </ResizablePanelGroup>\n    </div>\n  );\n\n  const historyEnabled = config?.dataPersistence && data?.requireLogin;\n  const sidebarHidden = config?.ui?.default_sidebar_state === 'hidden';\n\n  return (\n    <SidebarProvider\n      defaultOpen={config?.ui.default_sidebar_state !== 'closed'}\n    >\n      {historyEnabled && !sidebarHidden ? (\n        <>\n          <LeftSidebar />\n          <SidebarInset className=\"max-h-svh min-w-0\">\n            {mainContent}\n          </SidebarInset>\n        </>\n      ) : (\n        <div className=\"h-screen w-screen flex\">{mainContent}</div>\n      )}\n    </SidebarProvider>\n  );\n};\n\nexport default Page;\n"
  },
  {
    "path": "frontend/src/pages/Thread.tsx",
    "content": "import { useEffect } from 'react';\nimport { useLocation, useParams } from 'react-router-dom';\nimport { useSetRecoilState } from 'recoil';\n\nimport Page from 'pages/Page';\n\nimport {\n  threadHistoryState,\n  useChatMessages,\n  useConfig\n} from '@chainlit/react-client';\n\nimport AutoResumeThread from '@/components/AutoResumeThread';\nimport { Loader } from '@/components/Loader';\nimport { ReadOnlyThread } from '@/components/ReadOnlyThread';\nimport Chat from '@/components/chat';\n\nexport default function ThreadPage() {\n  const { id } = useParams();\n  const location = useLocation();\n  const { config } = useConfig();\n\n  const setThreadHistory = useSetRecoilState(threadHistoryState);\n\n  const { threadId } = useChatMessages();\n\n  const isCurrentThread = threadId === id;\n\n  useEffect(() => {\n    setThreadHistory((prev) => {\n      if (prev?.currentThreadId === id) return prev;\n      return { ...prev, currentThreadId: id };\n    });\n  }, [id]);\n\n  const isSharedRoute = location.pathname.startsWith('/share/');\n\n  return (\n    <Page>\n      <>\n  {isSharedRoute ? <ReadOnlyThread id={id!} /> : null}\n        {config?.threadResumable && !isCurrentThread && !isSharedRoute ? (\n          <AutoResumeThread id={id!} />\n        ) : null}\n        {config?.threadResumable && !isSharedRoute ? (\n          isCurrentThread ? (\n            <Chat />\n          ) : (\n            <div className=\"flex flex-grow items-center justify-center\">\n              <Loader className=\"!size-6\" />\n            </div>\n          )\n        ) : null}\n        {config && !config.threadResumable && !isSharedRoute ? (\n          isCurrentThread ? (\n            <Chat />\n          ) : (\n            <ReadOnlyThread id={id!} />\n          )\n        ) : null}\n      </>\n    </Page>\n  );\n}\n"
  },
  {
    "path": "frontend/src/router.tsx",
    "content": "import getRouterBasename from '@/lib/router';\nimport { Navigate, createBrowserRouter } from 'react-router-dom';\n\nimport AuthCallback from 'pages/AuthCallback';\nimport Element from 'pages/Element';\nimport Env from 'pages/Env';\nimport Home from 'pages/Home';\nimport Login from 'pages/Login';\nimport Thread from 'pages/Thread';\n\nexport const router = createBrowserRouter(\n  [\n    {\n      path: '/',\n      element: <Home />\n    },\n    {\n      path: '/env',\n      element: <Env />\n    },\n    {\n      path: '/thread/:id?',\n      element: <Thread />\n    },\n    {\n      path: '/element/:id',\n      element: <Element />\n    },\n    {\n      path: '/login',\n      element: <Login />\n    },\n    {\n      path: '/login/callback',\n      element: <AuthCallback />\n    },\n    {\n      path: '/share/:id',\n      element: <Thread />\n    },\n    {\n      path: '*',\n      element: <Navigate replace to=\"/\" />\n    }\n  ],\n  { basename: getRouterBasename() }\n);\n"
  },
  {
    "path": "frontend/src/state/chat.ts",
    "content": "import { atom } from 'recoil';\n\nimport { ICommand } from 'client-types/*';\n\nexport interface IAttachment {\n  id: string;\n  serverId?: string;\n  name: string;\n  size: number;\n  type: string;\n  uploadProgress?: number;\n  uploaded?: boolean;\n  cancel?: () => void;\n  remove?: () => void;\n  file?: File;\n}\n\nexport const attachmentsState = atom<IAttachment[]>({\n  key: 'Attachments',\n  default: []\n});\n\nexport const persistentCommandState = atom<ICommand | undefined>({\n  key: 'PersistentCommand',\n  default: undefined\n});\n"
  },
  {
    "path": "frontend/src/state/project.ts",
    "content": "import { atom } from 'recoil';\n\nexport const chatSettingsOpenState = atom<boolean>({\n  key: 'chatSettingsOpen',\n  default: false\n});\n\nexport const chatSettingsSidebarOpenState = atom<boolean>({\n  key: 'chatSettingsSidebarOpen',\n  default: false\n});\n"
  },
  {
    "path": "frontend/src/state/user.ts",
    "content": "import { atom } from 'recoil';\n\nconst localUserEnv = localStorage.getItem('userEnv');\n\nexport const userEnvState = atom<Record<string, string>>({\n  key: 'UserEnv',\n  default: localUserEnv ? JSON.parse(localUserEnv) : {}\n});\n"
  },
  {
    "path": "frontend/src/types/Input.ts",
    "content": "import { NotificationCountProps } from './NotificationCount';\n\ninterface IInput {\n  className?: string;\n  description?: string;\n  disabled?: boolean;\n  hasError?: boolean;\n  id: string;\n  label?: string;\n  notificationsProps?: NotificationCountProps;\n  tooltip?: string;\n}\n\nexport type { IInput };\n"
  },
  {
    "path": "frontend/src/types/NotificationCount.tsx",
    "content": "export type NotificationCountProps = {\n  count?: number | string;\n  inputProps?: {\n    id: string;\n    max?: number;\n    min?: number;\n    onChange: (event: any) => void;\n    step?: number;\n  };\n};\n"
  },
  {
    "path": "frontend/src/types/chat.ts",
    "content": "import { Socket } from '@chainlit/react-client';\n\nexport interface IToken {\n  id: number | string;\n  token: string;\n  isSequence: boolean;\n}\n\nexport interface ISession {\n  socket: Socket;\n  error?: boolean;\n}\n"
  },
  {
    "path": "frontend/src/types/index.ts",
    "content": "export * from './Input';\nexport * from './messageContext';\nexport * from './NotificationCount';\n"
  },
  {
    "path": "frontend/src/types/messageContext.ts",
    "content": "import type {\n  IAsk,\n  IFeedback,\n  IFileRef,\n  IMessageElement,\n  IStep\n} from '@chainlit/react-client';\n\ninterface IMessageContext {\n  uploadFile?: (\n    file: File,\n    onProgress: (progress: number) => void\n  ) => { xhr: XMLHttpRequest; promise: Promise<IFileRef> };\n  cot: 'hidden' | 'tool_call' | 'full';\n  askUser?: IAsk;\n  editable: boolean;\n  loading: boolean;\n  showFeedbackButtons: boolean;\n  uiName: string;\n  allowHtml?: boolean;\n  latex?: boolean;\n  renderUserMarkdown?: boolean;\n  onElementRefClick?: (element: IMessageElement) => void;\n  onFeedbackUpdated?: (\n    message: IStep,\n    onSuccess: () => void,\n    feedback: IFeedback\n  ) => void;\n  onFeedbackDeleted?: (\n    message: IStep,\n    onSuccess: () => void,\n    feedbackId: string\n  ) => void;\n  onError: (error: string) => void;\n}\n\nexport type { IMessageContext };\n"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-svgr/client\" />\n\ndeclare module 'react-mentions-continued';\n"
  },
  {
    "path": "frontend/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nimport animate from 'tailwindcss-animate';\n\nexport default {\n  darkMode: ['class'],\n  content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],\n  theme: {\n    extend: {\n      borderRadius: {\n        lg: 'var(--radius)',\n        md: 'calc(var(--radius) - 2px)',\n        sm: 'calc(var(--radius) - 4px)'\n      },\n      colors: {\n        background: 'hsl(var(--background))',\n        foreground: 'hsl(var(--foreground))',\n        card: {\n          DEFAULT: 'hsl(var(--card))',\n          foreground: 'hsl(var(--card-foreground))'\n        },\n        popover: {\n          DEFAULT: 'hsl(var(--popover))',\n          foreground: 'hsl(var(--popover-foreground))'\n        },\n        primary: {\n          DEFAULT: 'hsl(var(--primary))',\n          foreground: 'hsl(var(--primary-foreground))'\n        },\n        secondary: {\n          DEFAULT: 'hsl(var(--secondary))',\n          foreground: 'hsl(var(--secondary-foreground))'\n        },\n        muted: {\n          DEFAULT: 'hsl(var(--muted))',\n          foreground: 'hsl(var(--muted-foreground))'\n        },\n        accent: {\n          DEFAULT: 'hsl(var(--accent))',\n          foreground: 'hsl(var(--accent-foreground))'\n        },\n        destructive: {\n          DEFAULT: 'hsl(var(--destructive))',\n          foreground: 'hsl(var(--destructive-foreground))'\n        },\n        border: 'hsl(var(--border))',\n        input: 'hsl(var(--input))',\n        ring: 'hsl(var(--ring))',\n        chart: {\n          1: 'hsl(var(--chart-1))',\n          2: 'hsl(var(--chart-2))',\n          3: 'hsl(var(--chart-3))',\n          4: 'hsl(var(--chart-4))',\n          5: 'hsl(var(--chart-5))'\n        },\n        sidebar: {\n          DEFAULT: 'hsl(var(--sidebar-background))',\n          foreground: 'hsl(var(--sidebar-foreground))',\n          primary: 'hsl(var(--sidebar-primary))',\n          'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',\n          accent: 'hsl(var(--sidebar-accent))',\n          'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',\n          border: 'hsl(var(--sidebar-border))',\n          ring: 'hsl(var(--sidebar-ring))'\n        },\n        command: '#0066FF'\n      },\n      keyframes: {\n        'accordion-down': {\n          from: {\n            height: '0'\n          },\n          to: {\n            height: 'var(--radix-accordion-content-height)'\n          }\n        },\n        'accordion-up': {\n          from: {\n            height: 'var(--radix-accordion-content-height)'\n          },\n          to: {\n            height: '0'\n          }\n        },\n        'bounce-subtle': {\n          '0%, 100%': { transform: 'translateY(0) scale(1)' },\n          '50%': { transform: 'translateY(-2px) scale(1.05)' }\n        },\n        'command-shift': {\n          '0%': { transform: 'translateX(-10px)', opacity: '0.8' },\n          '50%': { transform: 'translateX(5px)' },\n          '100%': { transform: 'translateX(0)', opacity: '1' }\n        },\n        'slide-up': {\n          '0%': {\n            opacity: '0',\n            transform: 'translateY(10px) scale(0.95)'\n          },\n          '100%': {\n            opacity: '1',\n            transform: 'translateY(0) scale(1)'\n          }\n        },\n        'expand-width': {\n          '0%': { width: '0' },\n          '100%': { width: '30%' }\n        }\n      },\n      animation: {\n        'accordion-down': 'accordion-down 0.2s ease-out',\n        'accordion-up': 'accordion-up 0.2s ease-out',\n        'bounce-subtle': 'bounce-subtle 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',\n        'command-shift': 'command-shift 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',\n        'slide-up': 'slide-up 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',\n        'expand-width': 'expand-width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)'\n      },\n      transitionProperty: {\n        'width-padding': 'width, padding'\n      }\n    }\n  },\n  safelist: [\n    {\n      pattern:\n        /^(filter-none|blur(?:-\\w+)?|brightness-\\d+|contrast-\\d+|grayscale(?:-\\d+)?|hue-rotate-\\d+|-hue-rotate-\\d+|invert(?:-\\d+)?|saturate-\\d+|sepia(?:-\\d+)?)$/\n    }\n  ],\n  plugins: [animate]\n};\n"
  },
  {
    "path": "frontend/tests/FavoriteButton.spec.tsx",
    "content": "import { act, fireEvent, render, screen } from '@testing-library/react';\nimport { RecoilRoot } from 'recoil';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  IStep,\n  favoriteMessagesState,\n  useConfig\n} from '@chainlit/react-client';\n\nimport { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton';\n\nconst toggleMessageFavoriteMock = vi.fn();\n\nvi.mock('@/components/i18n/Translator', () => ({\n  useTranslation: () => ({\n    t: (key: string) => {\n      const trans: Record<string, string> = {\n        'chat.favorites.use': 'Use favorite',\n        'chat.favorites.headline': 'Favorites List',\n        'chat.favorites.empty.title': 'No Saved Prompts Yet',\n        'chat.favorites.empty.description':\n          'Start by sending a prompt and star it or star a prompt from previous chats',\n        'chat.favorites.remove': 'Remove favorite'\n      };\n      return trans[key] || key;\n    }\n  })\n}));\n\nvi.mock('@chainlit/react-client', async () => {\n  const { atom } = await import('recoil');\n  return {\n    useConfig: vi.fn(),\n    useChatInteract: () => ({\n      toggleMessageFavorite: toggleMessageFavoriteMock\n    }),\n    favoriteMessagesState: atom({\n      key: 'favoriteMessagesState',\n      default: []\n    })\n  };\n});\n\nglobal.ResizeObserver = class ResizeObserver {\n  observe() {}\n  unobserve() {}\n  disconnect() {}\n};\n\nwindow.HTMLElement.prototype.scrollIntoView = vi.fn();\n\ndescribe('FavoriteButton', () => {\n  const mockOnSelect = vi.fn();\n\n  const mockFavorites: IStep[] = [\n    {\n      id: 'msg_1',\n      output: 'How do I center a div?',\n      createdAt: new Date('2023-10-01').getTime(),\n      type: 'assistant_message',\n      name: 'Assistant'\n    },\n    {\n      id: 'msg_2',\n      output: 'Explain Quantum Physics',\n      createdAt: new Date('2023-10-05').getTime(),\n      type: 'assistant_message',\n      name: 'Assistant'\n    }\n  ];\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  const renderComponent = (favorites = mockFavorites, props = {}) => {\n    return render(\n      <RecoilRoot\n        initializeState={({ set }) => {\n          set(favoriteMessagesState, favorites);\n        }}\n      >\n        <FavoriteButton onSelect={mockOnSelect} {...props} />\n      </RecoilRoot>\n    );\n  };\n\n  it('returns null if the \"favorites\" feature is disabled in config', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: false } }\n    });\n\n    const { container } = renderComponent();\n    expect(container.firstChild).toBeNull();\n  });\n\n  it('renders the button with empty state when there are no favorites', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent([]);\n\n    const button = screen.getByRole('button');\n    expect(button).toBeInTheDocument();\n\n    // Click to open popover\n    fireEvent.click(button);\n\n    // Verify empty state message appears\n    expect(screen.getByText('No Saved Prompts Yet')).toBeInTheDocument();\n    expect(\n      screen.getByText(\n        'Start by sending a prompt and star it or star a prompt from previous chats'\n      )\n    ).toBeInTheDocument();\n  });\n\n  it('shows empty state message when popover is opened with no favorites', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent([]);\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    // Empty state should be visible\n    const emptyTitle = screen.getByText('No Saved Prompts Yet');\n    const emptyDescription = screen.getByText(\n      'Start by sending a prompt and star it or star a prompt from previous chats'\n    );\n\n    expect(emptyTitle).toBeInTheDocument();\n    expect(emptyDescription).toBeInTheDocument();\n\n    // Regular favorites list heading should not be visible\n    expect(screen.queryByText('Favorites List')).not.toBeInTheDocument();\n  });\n\n  it('renders the button when feature is enabled and favorites exist', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n\n    const button = screen.getByRole('button');\n    expect(button).toBeInTheDocument();\n    expect(button.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('opens the popover and displays the list of favorites when clicked', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    expect(screen.getByText('Favorites List')).toBeInTheDocument();\n    expect(screen.getByText('How do I center a div?')).toBeInTheDocument();\n    expect(screen.getByText('Explain Quantum Physics')).toBeInTheDocument();\n    expect(\n      screen.getByText(new Date('2023-10-01').toLocaleDateString())\n    ).toBeInTheDocument();\n  });\n\n  it('triggers onSelect with the correct output when an item is clicked', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    const item = screen.getByText('How do I center a div?');\n    fireEvent.click(item);\n\n    expect(mockOnSelect).toHaveBeenCalledTimes(1);\n    expect(mockOnSelect).toHaveBeenCalledWith('How do I center a div?');\n  });\n\n  it('renders favorites with duplicate text independently', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    const duplicates: IStep[] = [\n      {\n        id: 'msg_dup_1',\n        output: 'Same prompt',\n        createdAt: new Date('2023-10-02').getTime(),\n        type: 'assistant_message',\n        name: 'Assistant'\n      },\n      {\n        id: 'msg_dup_2',\n        output: 'Same prompt',\n        createdAt: new Date('2023-10-03').getTime(),\n        type: 'assistant_message',\n        name: 'Assistant'\n      }\n    ];\n\n    renderComponent(duplicates);\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    const duplicateEntries = screen.getAllByText('Same prompt');\n    expect(duplicateEntries).toHaveLength(2);\n  });\n\n  it('removes a favorite without selecting the item', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    const removeButtons = screen.getAllByLabelText('Remove favorite');\n    fireEvent.click(removeButtons[0]);\n\n    expect(toggleMessageFavoriteMock).toHaveBeenCalledTimes(1);\n    expect(toggleMessageFavoriteMock).toHaveBeenCalledWith(mockFavorites[0]);\n    expect(mockOnSelect).not.toHaveBeenCalled();\n  });\n\n  it('respects the disabled prop', () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent(mockFavorites, { disabled: true });\n\n    const button = screen.getByRole('button');\n    expect(button).toBeDisabled();\n  });\n\n  it('shows tooltip text after delay on hover', async () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n    const button = screen.getByRole('button');\n    fireEvent.mouseEnter(button);\n    expect(screen.queryByText('Use favorite')).not.toBeInTheDocument();\n    act(() => {\n      vi.advanceTimersByTime(800);\n    });\n\n    const tooltips = screen.getAllByText('Use favorite');\n    expect(tooltips.length).toBeGreaterThan(0);\n  });\n\n  it('cancels tooltip if mouse leaves before delay', async () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n    const button = screen.getByRole('button');\n\n    fireEvent.mouseEnter(button);\n    fireEvent.mouseLeave(button);\n    act(() => {\n      vi.advanceTimersByTime(800);\n    });\n    expect(screen.queryByText('Use favorite')).not.toBeInTheDocument();\n  });\n\n  it('hides the tooltip instantly when the popover opens', async () => {\n    (useConfig as any).mockReturnValue({\n      config: { features: { favorites: true } }\n    });\n\n    renderComponent();\n    const button = screen.getByRole('button');\n\n    fireEvent.mouseEnter(button);\n    act(() => {\n      vi.advanceTimersByTime(800);\n    });\n    expect(screen.getAllByText('Use favorite').length).toBeGreaterThan(0);\n\n    fireEvent.click(button);\n    expect(screen.queryByText('Use favorite')).not.toBeInTheDocument();\n    expect(screen.getByText('Favorites List')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/tests/NewChat.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport NewChatButton from '@/components/header/NewChat';\n\nconst mockClear = vi.fn();\nconst mockUseConfig = vi.fn();\n\nvi.mock('@chainlit/react-client', () => ({\n  useChatInteract: () => ({ clear: mockClear }),\n  useConfig: () => mockUseConfig()\n}));\n\nvi.mock('@/components/i18n', () => ({\n  Translator: ({ path }: { path: string }) => <span>{path}</span>\n}));\n\ndescribe('NewChatButton', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockUseConfig.mockReturnValue({ config: {} });\n  });\n\n  it('renders the button correctly', () => {\n    render(<NewChatButton />);\n    const button = screen.getByRole('button');\n    expect(button).toBeInTheDocument();\n    expect(button.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('opens dialog by default when config is undefined', () => {\n    mockUseConfig.mockReturnValue({ config: undefined });\n\n    render(<NewChatButton />);\n    fireEvent.click(screen.getByRole('button'));\n\n    expect(\n      screen.getByText('navigation.newChat.dialog.title')\n    ).toBeInTheDocument();\n  });\n\n  it('opens dialog by default when ui config is missing', () => {\n    mockUseConfig.mockReturnValue({ config: { project: {} } });\n\n    render(<NewChatButton />);\n    fireEvent.click(screen.getByRole('button'));\n\n    expect(\n      screen.getByText('navigation.newChat.dialog.title')\n    ).toBeInTheDocument();\n  });\n\n  it('clears chat and navigates when confirmed via Dialog', () => {\n    mockUseConfig.mockReturnValue({ config: {} });\n    const mockNavigate = vi.fn();\n\n    render(<NewChatButton navigate={mockNavigate} />);\n\n    fireEvent.click(screen.getByRole('button'));\n\n    const confirmBtn = screen.getByText('common.actions.confirm');\n    fireEvent.click(confirmBtn);\n\n    expect(mockClear).toHaveBeenCalledTimes(1);\n    expect(mockNavigate).toHaveBeenCalledWith('/');\n  });\n\n  it('skips dialog and activates immediately when confirm_new_chat is false', () => {\n    mockUseConfig.mockReturnValue({\n      config: { ui: { confirm_new_chat: false } }\n    });\n\n    const mockNavigate = vi.fn();\n    render(<NewChatButton navigate={mockNavigate} />);\n\n    fireEvent.click(screen.getByRole('button'));\n\n    expect(\n      screen.queryByText('navigation.newChat.dialog.title')\n    ).not.toBeInTheDocument();\n    expect(mockClear).toHaveBeenCalledTimes(1);\n    expect(mockNavigate).toHaveBeenCalledWith('/');\n  });\n\n  it('opens dialog explicitly when confirm_new_chat is true', () => {\n    mockUseConfig.mockReturnValue({\n      config: { ui: { confirm_new_chat: true } }\n    });\n\n    render(<NewChatButton />);\n    fireEvent.click(screen.getByRole('button'));\n\n    expect(\n      screen.getByText('navigation.newChat.dialog.title')\n    ).toBeInTheDocument();\n    expect(mockClear).not.toHaveBeenCalled();\n  });\n\n  it('uses custom onConfirm handler if provided', () => {\n    mockUseConfig.mockReturnValue({\n      config: { ui: { confirm_new_chat: false } }\n    });\n\n    const customOnConfirm = vi.fn();\n    render(<NewChatButton onConfirm={customOnConfirm} />);\n\n    fireEvent.click(screen.getByRole('button'));\n\n    expect(customOnConfirm).toHaveBeenCalledTimes(1);\n    expect(mockClear).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "frontend/tests/content.spec.tsx",
    "content": "import { render } from '@testing-library/react';\nimport { expect, it } from 'vitest';\n\nimport { MessageContent } from 'components/chat/Messages/Message/Content';\n\n// Import the toBeInTheDocument function\nimport type { ITextElement } from '@chainlit/react-client';\n\nit('renders the message content', () => {\n  const { getByText } = render(\n    <MessageContent\n      message={{\n        threadId: 'test',\n        type: 'assistant_message',\n        output: 'Hello World',\n        id: 'test',\n        name: 'User',\n        createdAt: 0\n      }}\n      elements={[]}\n    />\n  );\n  expect(getByText('Hello World')).toBeInTheDocument();\n});\n\nit('highlights multiple sources correctly (no substring matching)', () => {\n  const { getByRole } = render(\n    <MessageContent\n      message={{\n        threadId: 'test',\n        type: 'assistant_message',\n        output: `Hello world source_121, source_1, source_12`,\n        id: 'test2',\n        name: 'Test',\n        createdAt: 0\n      }}\n      elements={[\n        {\n          name: 'source_1',\n          type: 'text',\n          display: 'side',\n          url: 'source_1',\n          forId: 'test2'\n        } as ITextElement,\n        {\n          name: 'source_12',\n          display: 'side',\n          type: 'text',\n          forId: 'test2',\n          url: 'hi'\n        } as ITextElement,\n        {\n          name: 'source_121',\n          display: 'side',\n          type: 'text',\n          forId: 'test2',\n          url: 'hi'\n        } as ITextElement\n      ]}\n    />\n  );\n  expect(getByRole('link', { name: 'source_1' })).toBeInTheDocument();\n  expect(getByRole('link', { name: 'source_12' })).toBeInTheDocument();\n  expect(getByRole('link', { name: 'source_121' })).toBeInTheDocument();\n});\n\nit('highlights sources containing regex characters correctly', () => {\n  const { getByRole } = render(\n    <MessageContent\n      message={{\n        threadId: 'test',\n        type: 'assistant_message',\n        output: `Hello world: Document[1], source(12), page{12}`,\n        id: 'test2',\n        name: 'Test',\n        createdAt: 0\n      }}\n      elements={[\n        {\n          name: 'Document[1]',\n          display: 'side',\n          type: 'text',\n          url: 'hi',\n          forId: 'test2'\n        } as ITextElement,\n        {\n          name: 'source(12)',\n          display: 'side',\n          type: 'text',\n          url: 'hi',\n          forId: 'test2'\n        } as ITextElement,\n        {\n          name: 'page{12}',\n          display: 'side',\n          type: 'text',\n          url: 'hi',\n          forId: 'test2'\n        } as ITextElement\n      ]}\n    />\n  );\n  expect(getByRole('link', { name: 'Document[1]' })).toBeInTheDocument();\n  expect(getByRole('link', { name: 'source(12)' })).toBeInTheDocument();\n  expect(getByRole('link', { name: 'page{12}' })).toBeInTheDocument();\n});"
  },
  {
    "path": "frontend/tests/icon.spec.tsx",
    "content": "import { render } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport Icon from '@/components/Icon';\n\ndescribe('Icon component', () => {\n  it('renders icon with kebab-case name', () => {\n    const { container } = render(<Icon name=\"chevron-right\" />);\n    expect(container.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('renders icon with lowercase name', () => {\n    const { container } = render(<Icon name=\"plus\" />);\n    expect(container.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('renders icon with PascalCase name', () => {\n    const { container } = render(<Icon name=\"ChevronRight\" />);\n    expect(container.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('renders icon with all uppercase name', () => {\n    const { container } = render(<Icon name=\"PLUS\" />);\n    expect(container.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('renders icon with mixed case name', () => {\n    const { container } = render(<Icon name=\"ChEvRoN-rIgHt\" />);\n    expect(container.querySelector('svg')).toBeInTheDocument();\n  });\n\n  it('returns null and warns for invalid icon name', () => {\n    const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n    const { container } = render(<Icon name=\"invalid-icon-name\" />);\n\n    expect(container.firstChild).toBeNull();\n    expect(consoleSpy).toHaveBeenCalledWith(\n      'Icon \"invalid-icon-name\" not found in Lucide icons'\n    );\n\n    consoleSpy.mockRestore();\n  });\n\n  it('passes props to the icon component', () => {\n    const { container } = render(\n      <Icon name=\"home\" size={24} color=\"red\" className=\"test-class\" />\n    );\n    const svg = container.querySelector('svg');\n\n    expect(svg).toBeInTheDocument();\n    expect(svg).toHaveClass('test-class');\n  });\n});\n"
  },
  {
    "path": "frontend/tests/setup-tests.ts",
    "content": "import matchers from '@testing-library/jest-dom/matchers';\nimport { cleanup } from '@testing-library/react';\nimport { afterEach, expect, vi } from 'vitest';\n\nexpect.extend(matchers);\n\n// Mock URL.createObjectURL\nglobal.URL.createObjectURL = vi.fn();\n\nafterEach(() => {\n  cleanup();\n});\n"
  },
  {
    "path": "frontend/tests/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"types\": [\"jest\", \"testing-library__jest-dom\"]\n  },\n  \"include\": [\"**/*.spec.tsx\"]\n}"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"target\": \"ESNext\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"baseUrl\": \"./src\",\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"strictNullChecks\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"types\": [\"node\"],\n    \"paths\": {\n      \"client-types/*\": [\"../../libs/react-client/dist\"],\n      \"@/*\": [\"./*\"]\n    }\n  },\n  \"include\": [\"./src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react-swc';\nimport path from 'path';\nimport { defineConfig } from 'vite';\nimport svgr from 'vite-plugin-svgr';\nimport tsconfigPaths from 'vite-tsconfig-paths';\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  build: {\n    sourcemap: true\n  },\n  plugins: [react(), tsconfigPaths(), svgr()],\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src'),\n      // To prevent conflicts with packages in @chainlit/react-client, we need to specify the resolution paths for these dependencies.\n      react: path.resolve(__dirname, './node_modules/react'),\n      'usehooks-ts': path.resolve(__dirname, './node_modules/usehooks-ts'),\n      sonner: path.resolve(__dirname, './node_modules/sonner'),\n      lodash: path.resolve(__dirname, './node_modules/lodash'),\n      recoil: path.resolve(__dirname, './node_modules/recoil')\n    }\n  }\n});\n"
  },
  {
    "path": "frontend/vitest.config.ts",
    "content": "/// <reference types=\"vitest\" />\nimport react from '@vitejs/plugin-react-swc';\nimport { defineConfig } from 'vite';\nimport tsconfigPaths from 'vite-tsconfig-paths';\n\nexport default defineConfig({\n  plugins: [tsconfigPaths(), react()],\n  test: {\n    environment: 'jsdom',\n    setupFiles: './tests/setup-tests.ts',\n    include: ['./**/*.{test,spec}.?(c|m)[jt]s?(x)']\n  }\n});\n"
  },
  {
    "path": "libs/copilot/.storybook/main.ts",
    "content": "import type { StorybookConfig } from '@storybook/react-vite';\n\nconst config: StorybookConfig = {\n  stories: [\n    '../stories/**/*.mdx',\n    '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'\n  ],\n  addons: [\n    '@storybook/addon-links',\n    '@storybook/addon-essentials',\n    '@storybook/addon-onboarding',\n    '@storybook/addon-interactions'\n  ],\n  framework: {\n    name: '@storybook/react-vite',\n    options: {}\n  },\n  docs: {\n    autodocs: 'tag'\n  }\n};\nexport default config;\n"
  },
  {
    "path": "libs/copilot/.storybook/preview.ts",
    "content": "import '../src/index.css'\n\nimport type { Preview } from '@storybook/react';\n\nconst preview: Preview = {\n  parameters: {\n    actions: { argTypesRegex: '^on[A-Z].*' },\n    controls: {\n      matchers: {\n        color: /(background|color)$/i,\n        date: /Date$/i\n      }\n    }\n  }\n};\n\nexport default preview;\n"
  },
  {
    "path": "libs/copilot/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@chainlit/app/src/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@chainlit/app/src/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "libs/copilot/index.tsx",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\n\nimport { type IStep } from '@chainlit/react-client';\n\n// @ts-expect-error inline css\nimport sonnercss from './sonner.css?inline';\n// @ts-expect-error inline css\nimport tailwindcss from './src/index.css?inline';\n// @ts-expect-error inline css\nimport hljscss from 'highlight.js/styles/monokai-sublime.css?inline';\n\nimport AppWrapper from './src/appWrapper';\nimport {\n  clearChainlitCopilotThreadId,\n  getChainlitCopilotThreadId\n} from './src/state';\nimport { IWidgetConfig } from './src/types';\n\nconst id = 'chainlit-copilot';\nlet root: ReactDOM.Root | null = null;\n\ndeclare global {\n  interface Window {\n    cl_shadowRootElement: HTMLDivElement;\n    theme?: {\n      light: Record<string, string>;\n      dark: Record<string, string>;\n    };\n    mountChainlitWidget: (config: IWidgetConfig) => void;\n    unmountChainlitWidget: () => void;\n    toggleChainlitCopilot: () => void;\n    sendChainlitMessage: (message: IStep) => void;\n    getChainlitCopilotThreadId: () => string | null;\n    clearChainlitCopilotThreadId: (newThreadId?: string) => void;\n  }\n}\n\nwindow.mountChainlitWidget = (config: IWidgetConfig) => {\n  const container = document.createElement('div');\n  container.id = id;\n  document.body.appendChild(container);\n\n  const shadowContainer = container.attachShadow({ mode: 'open' });\n  const shadowRootElement = document.createElement('div');\n  shadowRootElement.id = 'cl-shadow-root';\n  shadowContainer.appendChild(shadowRootElement);\n\n  window.cl_shadowRootElement = shadowRootElement;\n\n  root = ReactDOM.createRoot(shadowRootElement);\n  root.render(\n    <React.StrictMode>\n      <style type=\"text/css\">{tailwindcss.toString()}</style>\n      <style type=\"text/css\">{sonnercss.toString()}</style>\n      <style type=\"text/css\">{hljscss.toString()}</style>\n      <AppWrapper widgetConfig={config} />\n    </React.StrictMode>\n  );\n};\n\nwindow.unmountChainlitWidget = () => {\n  root?.unmount();\n};\n\nwindow.sendChainlitMessage = () => {\n  console.info('Copilot is not active. Please check if the widget is mounted.');\n};\n\nwindow.getChainlitCopilotThreadId = getChainlitCopilotThreadId;\nwindow.clearChainlitCopilotThreadId = clearChainlitCopilotThreadId;\n"
  },
  {
    "path": "libs/copilot/package.json",
    "content": "{\n  \"name\": \"@chainlit/copilot\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"preinstall\": \"npx only-allow pnpm\",\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint ./src --ext .ts,.tsx\",\n    \"format\": \"prettier 'src/**/*.{ts,tsx}' --write\",\n    \"storybook\": \"storybook dev -p 6006\",\n    \"build-storybook\": \"storybook build\"\n  },\n  \"dependencies\": {\n    \"@chainlit/app\": \"workspace:^\",\n    \"@chainlit/react-client\": \"workspace:^\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"highlight.js\": \"^11.9.0\",\n    \"i18next\": \"^23.7.16\",\n    \"lodash\": \"^4.17.21\",\n    \"lucide-react\": \"^0.468.0\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-i18next\": \"^14.0.0\",\n    \"recoil\": \"^0.7.7\",\n    \"sonner\": \"^1.2.3\",\n    \"tailwind-merge\": \"^2.5.5\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"uuid\": \"^9.0.0\"\n  },\n  \"devDependencies\": {\n    \"@storybook/addon-essentials\": \"^8.4.7\",\n    \"@storybook/addon-interactions\": \"^8.4.7\",\n    \"@storybook/addon-links\": \"^8.4.7\",\n    \"@storybook/addon-onboarding\": \"^1.0.11\",\n    \"@storybook/blocks\": \"^8.4.7\",\n    \"@storybook/react\": \"^8.4.7\",\n    \"@storybook/react-vite\": \"^8.4.7\",\n    \"@storybook/test\": \"^8.4.7\",\n    \"@swc/core\": \"^1.3.86\",\n    \"@types/lodash\": \"^4.14.199\",\n    \"@types/node\": \"^20.5.7\",\n    \"@types/react\": \"^18.3.1\",\n    \"@types/react-file-icon\": \"^1.0.2\",\n    \"@types/uuid\": \"^9.0.3\",\n    \"@vitejs/plugin-react-swc\": \"^3.3.2\",\n    \"autoprefixer\": \"^10.4.20\",\n    \"postcss\": \"^8.4.49\",\n    \"storybook\": \"^8.4.7\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.4.14\",\n    \"vite-plugin-svgr\": \"^4.2.0\",\n    \"vite-tsconfig-paths\": \"^4.2.0\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite@>=4.4.0 <4.4.12\": \">=4.4.12\",\n      \"vite@>=4.0.0 <=4.5.1\": \">=4.5.2\",\n      \"express@<4.19.2\": \">=4.19.2\",\n      \"vite@>=4.0.0 <=4.5.2\": \">=4.5.3\",\n      \"tar@<6.2.1\": \">=6.2.1\",\n      \"braces@<3.0.3\": \">=3.0.3\",\n      \"ejs@<3.1.10\": \">=3.1.10\",\n      \"ws@>=8.0.0 <8.17.1\": \">=8.17.1\",\n      \"micromatch@<4.0.8\": \">=4.0.8\",\n      \"body-parser@<1.20.3\": \">=1.20.3\",\n      \"send@<0.19.0\": \">=0.19.0\",\n      \"serve-static@<1.16.0\": \">=1.16.0\",\n      \"express@<4.20.0\": \">=4.20.0\",\n      \"path-to-regexp@<0.1.10\": \">=0.1.10\",\n      \"vite@>=4.0.0 <4.5.4\": \">=4.5.4\",\n      \"vite@>=4.0.0 <=4.5.3\": \">=4.5.4\",\n      \"rollup@>=3.0.0 <3.29.5\": \">=3.29.5\",\n      \"cookie@<0.7.0\": \">=0.7.0\",\n      \"cross-spawn@>=7.0.0 <7.0.5\": \">=7.0.5\"\n    }\n  }\n}\n"
  },
  {
    "path": "libs/copilot/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "libs/copilot/sonner.css",
    "content": ":where(html[dir='ltr']),\n:where([data-sonner-toaster][dir='ltr']) {\n  --toast-icon-margin-start: -3px;\n  --toast-icon-margin-end: 4px;\n  --toast-svg-margin-start: -1px;\n  --toast-svg-margin-end: 0px;\n  --toast-button-margin-start: auto;\n  --toast-button-margin-end: 0;\n  --toast-close-button-start: 0;\n  --toast-close-button-end: unset;\n  --toast-close-button-transform: translate(-35%, -35%);\n}\n\n:where(html[dir='rtl']),\n:where([data-sonner-toaster][dir='rtl']) {\n  --toast-icon-margin-start: 4px;\n  --toast-icon-margin-end: -3px;\n  --toast-svg-margin-start: 0px;\n  --toast-svg-margin-end: -1px;\n  --toast-button-margin-start: 0;\n  --toast-button-margin-end: auto;\n  --toast-close-button-start: unset;\n  --toast-close-button-end: 0;\n  --toast-close-button-transform: translate(35%, -35%);\n}\n\n:where([data-sonner-toaster]) {\n  position: fixed;\n  width: var(--width);\n  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,\n    Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif,\n    Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;\n  --gray1: hsl(0, 0%, 99%);\n  --gray2: hsl(0, 0%, 97.3%);\n  --gray3: hsl(0, 0%, 95.1%);\n  --gray4: hsl(0, 0%, 93%);\n  --gray5: hsl(0, 0%, 90.9%);\n  --gray6: hsl(0, 0%, 88.7%);\n  --gray7: hsl(0, 0%, 85.8%);\n  --gray8: hsl(0, 0%, 78%);\n  --gray9: hsl(0, 0%, 56.1%);\n  --gray10: hsl(0, 0%, 52.3%);\n  --gray11: hsl(0, 0%, 43.5%);\n  --gray12: hsl(0, 0%, 9%);\n  --border-radius: 8px;\n  box-sizing: border-box;\n  padding: 0;\n  margin: 0;\n  list-style: none;\n  outline: none;\n  z-index: 999999999;\n}\n\n:where([data-sonner-toaster][data-x-position='right']) {\n  right: max(var(--offset), env(safe-area-inset-right));\n}\n\n:where([data-sonner-toaster][data-x-position='left']) {\n  left: max(var(--offset), env(safe-area-inset-left));\n}\n\n:where([data-sonner-toaster][data-x-position='center']) {\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n:where([data-sonner-toaster][data-y-position='top']) {\n  top: max(var(--offset), env(safe-area-inset-top));\n}\n\n:where([data-sonner-toaster][data-y-position='bottom']) {\n  bottom: max(var(--offset), env(safe-area-inset-bottom));\n}\n\n:where([data-sonner-toast]) {\n  --y: translateY(100%);\n  --lift-amount: calc(var(--lift) * var(--gap));\n  z-index: var(--z-index);\n  position: absolute;\n  opacity: 0;\n  transform: var(--y);\n  filter: blur(0);\n  /* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */\n  touch-action: none;\n  transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms;\n  box-sizing: border-box;\n  outline: none;\n  overflow-wrap: anywhere;\n}\n\n:where([data-sonner-toast][data-styled='true']) {\n  padding: 16px;\n  background: var(--normal-bg);\n  border: 1px solid var(--normal-border);\n  color: var(--normal-text);\n  border-radius: var(--border-radius);\n  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);\n  width: var(--width);\n  font-size: 13px;\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n:where([data-sonner-toast]:focus-visible) {\n  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);\n}\n\n:where([data-sonner-toast][data-y-position='top']) {\n  top: 0;\n  --y: translateY(-100%);\n  --lift: 1;\n  --lift-amount: calc(1 * var(--gap));\n}\n\n:where([data-sonner-toast][data-y-position='bottom']) {\n  bottom: 0;\n  --y: translateY(100%);\n  --lift: -1;\n  --lift-amount: calc(var(--lift) * var(--gap));\n}\n\n:where([data-sonner-toast]) :where([data-description]) {\n  font-weight: 400;\n  line-height: 1.4;\n  color: inherit;\n}\n\n:where([data-sonner-toast]) :where([data-title]) {\n  font-weight: 500;\n  line-height: 1.5;\n  color: inherit;\n}\n\n:where([data-sonner-toast]) :where([data-icon]) {\n  display: flex;\n  height: 16px;\n  width: 16px;\n  position: relative;\n  justify-content: flex-start;\n  align-items: center;\n  flex-shrink: 0;\n  margin-left: var(--toast-icon-margin-start);\n  margin-right: var(--toast-icon-margin-end);\n}\n\n:where([data-sonner-toast][data-promise='true']) :where([data-icon]) > svg {\n  opacity: 0;\n  transform: scale(0.8);\n  transform-origin: center;\n  animation: sonner-fade-in 300ms ease forwards;\n}\n\n:where([data-sonner-toast]) :where([data-icon]) > * {\n  flex-shrink: 0;\n}\n\n:where([data-sonner-toast]) :where([data-icon]) svg {\n  margin-left: var(--toast-svg-margin-start);\n  margin-right: var(--toast-svg-margin-end);\n}\n\n:where([data-sonner-toast]) :where([data-content]) {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n[data-sonner-toast][data-styled='true'] [data-button] {\n  border-radius: 4px;\n  padding-left: 8px;\n  padding-right: 8px;\n  height: 24px;\n  font-size: 12px;\n  color: var(--normal-bg);\n  background: var(--normal-text);\n  margin-left: var(--toast-button-margin-start);\n  margin-right: var(--toast-button-margin-end);\n  border: none;\n  cursor: pointer;\n  outline: none;\n  display: flex;\n  align-items: center;\n  flex-shrink: 0;\n  transition: opacity 400ms, box-shadow 200ms;\n}\n\n:where([data-sonner-toast]) :where([data-button]):focus-visible {\n  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);\n}\n\n:where([data-sonner-toast]) :where([data-button]):first-of-type {\n  margin-left: var(--toast-button-margin-start);\n  margin-right: var(--toast-button-margin-end);\n}\n\n:where([data-sonner-toast]) :where([data-cancel]) {\n  color: var(--normal-text);\n  background: rgba(0, 0, 0, 0.08);\n}\n\n:where([data-sonner-toast][data-theme='dark']) :where([data-cancel]) {\n  background: rgba(255, 255, 255, 0.3);\n}\n\n:where([data-sonner-toast]) :where([data-close-button]) {\n  position: absolute;\n  left: var(--toast-close-button-start);\n  right: var(--toast-close-button-end);\n  top: 0;\n  height: 20px;\n  width: 20px;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  padding: 0;\n  background: var(--gray1);\n  color: var(--gray12);\n  border: 1px solid var(--gray4);\n  transform: var(--toast-close-button-transform);\n  border-radius: 50%;\n  cursor: pointer;\n  z-index: 1;\n  transition: opacity 100ms, background 200ms, border-color 200ms;\n}\n\n:where([data-sonner-toast]) :where([data-close-button]):focus-visible {\n  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);\n}\n\n:where([data-sonner-toast]) :where([data-disabled='true']) {\n  cursor: not-allowed;\n}\n\n:where([data-sonner-toast]):hover :where([data-close-button]):hover {\n  background: var(--gray2);\n  border-color: var(--gray5);\n}\n\n/* Leave a ghost div to avoid setting hover to false when swiping out */\n:where([data-sonner-toast][data-swiping='true'])::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  right: 0;\n  height: 100%;\n  z-index: -1;\n}\n\n:where(\n    [data-sonner-toast][data-y-position='top'][data-swiping='true']\n  )::before {\n  /* y 50% needed to distribute height additional height evenly */\n  bottom: 50%;\n  transform: scaleY(3) translateY(50%);\n}\n\n:where(\n    [data-sonner-toast][data-y-position='bottom'][data-swiping='true']\n  )::before {\n  /* y -50% needed to distribute height additional height evenly */\n  top: 50%;\n  transform: scaleY(3) translateY(-50%);\n}\n\n/* Leave a ghost div to avoid setting hover to false when transitioning out */\n:where([data-sonner-toast][data-swiping='false'][data-removed='true'])::before {\n  content: '';\n  position: absolute;\n  inset: 0;\n  transform: scaleY(2);\n}\n\n/* Needed to avoid setting hover to false when inbetween toasts */\n:where([data-sonner-toast])::after {\n  content: '';\n  position: absolute;\n  left: 0;\n  height: calc(var(--gap) + 1px);\n  bottom: 100%;\n  width: 100%;\n}\n\n:where([data-sonner-toast][data-mounted='true']) {\n  --y: translateY(0);\n  opacity: 1;\n}\n\n:where([data-sonner-toast][data-expanded='false'][data-front='false']) {\n  --scale: var(--toasts-before) * 0.05 + 1;\n  --y: translateY(calc(var(--lift-amount) * var(--toasts-before)))\n    scale(calc(-1 * var(--scale)));\n  height: var(--front-toast-height);\n}\n\n:where([data-sonner-toast]) > * {\n  transition: opacity 400ms;\n}\n\n:where(\n    [data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true']\n  )\n  > * {\n  opacity: 0;\n}\n\n:where([data-sonner-toast][data-visible='false']) {\n  opacity: 0;\n  pointer-events: none;\n}\n\n:where([data-sonner-toast][data-mounted='true'][data-expanded='true']) {\n  --y: translateY(calc(var(--lift) * var(--offset)));\n  height: var(--initial-height);\n}\n\n:where(\n    [data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false']\n  ) {\n  --y: translateY(calc(var(--lift) * -100%));\n  opacity: 0;\n}\n\n:where(\n    [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']\n  ) {\n  --y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));\n  opacity: 0;\n}\n\n:where(\n    [data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']\n  ) {\n  --y: translateY(40%);\n  opacity: 0;\n  transition: transform 500ms, opacity 200ms;\n}\n\n/* Bump up the height to make sure hover state doesn't get set to false */\n:where([data-sonner-toast][data-removed='true'][data-front='false'])::before {\n  height: calc(var(--initial-height) + 20%);\n}\n\n[data-sonner-toast][data-swiping='true'] {\n  transform: var(--y) translateY(var(--swipe-amount, 0px));\n  transition: none;\n}\n\n[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],\n[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {\n  animation: swipe-out 200ms ease-out forwards;\n}\n\n@keyframes swipe-out {\n  from {\n    transform: translateY(\n      calc(var(--lift) * var(--offset) + var(--swipe-amount))\n    );\n    opacity: 1;\n  }\n\n  to {\n    transform: translateY(\n      calc(\n        var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%\n      )\n    );\n    opacity: 0;\n  }\n}\n\n@media (max-width: 600px) {\n  [data-sonner-toaster] {\n    position: fixed;\n    --mobile-offset: 16px;\n    right: var(--mobile-offset);\n    left: var(--mobile-offset);\n    width: 100%;\n  }\n\n  [data-sonner-toaster] [data-sonner-toast] {\n    left: 0;\n    right: 0;\n    width: calc(100% - var(--mobile-offset) * 2);\n  }\n\n  [data-sonner-toaster][data-x-position='left'] {\n    left: var(--mobile-offset);\n  }\n\n  [data-sonner-toaster][data-y-position='bottom'] {\n    bottom: 20px;\n  }\n\n  [data-sonner-toaster][data-y-position='top'] {\n    top: 20px;\n  }\n\n  [data-sonner-toaster][data-x-position='center'] {\n    left: var(--mobile-offset);\n    right: var(--mobile-offset);\n    transform: none;\n  }\n}\n\n[data-sonner-toaster][data-theme='light'] {\n  --normal-bg: #fff;\n  --normal-border: var(--gray4);\n  --normal-text: var(--gray12);\n\n  --success-bg: hsl(143, 85%, 96%);\n  --success-border: hsl(145, 92%, 91%);\n  --success-text: hsl(140, 100%, 27%);\n\n  --info-bg: hsl(208, 100%, 97%);\n  --info-border: hsl(221, 91%, 91%);\n  --info-text: hsl(210, 92%, 45%);\n\n  --warning-bg: hsl(49, 100%, 97%);\n  --warning-border: hsl(49, 91%, 91%);\n  --warning-text: hsl(31, 92%, 45%);\n\n  --error-bg: hsl(359, 100%, 97%);\n  --error-border: hsl(359, 100%, 94%);\n  --error-text: hsl(360, 100%, 45%);\n}\n\n[data-sonner-toaster][data-theme='light']\n  [data-sonner-toast][data-invert='true'] {\n  --normal-bg: #000;\n  --normal-border: hsl(0, 0%, 20%);\n  --normal-text: var(--gray1);\n}\n\n[data-sonner-toaster][data-theme='dark']\n  [data-sonner-toast][data-invert='true'] {\n  --normal-bg: #fff;\n  --normal-border: var(--gray3);\n  --normal-text: var(--gray12);\n}\n\n[data-sonner-toaster][data-theme='dark'] {\n  --normal-bg: #000;\n  --normal-border: hsl(0, 0%, 20%);\n  --normal-text: var(--gray1);\n\n  --success-bg: hsl(150, 100%, 6%);\n  --success-border: hsl(147, 100%, 12%);\n  --success-text: hsl(150, 86%, 65%);\n\n  --info-bg: hsl(215, 100%, 6%);\n  --info-border: hsl(223, 100%, 12%);\n  --info-text: hsl(216, 87%, 65%);\n\n  --warning-bg: hsl(64, 100%, 6%);\n  --warning-border: hsl(60, 100%, 12%);\n  --warning-text: hsl(46, 87%, 65%);\n\n  --error-bg: hsl(358, 76%, 10%);\n  --error-border: hsl(357, 89%, 16%);\n  --error-text: hsl(358, 100%, 81%);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='success'] {\n  background: var(--success-bg);\n  border-color: var(--success-border);\n  color: var(--success-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='success']\n  [data-close-button] {\n  background: var(--success-bg);\n  border-color: var(--success-border);\n  color: var(--success-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='info'] {\n  background: var(--info-bg);\n  border-color: var(--info-border);\n  color: var(--info-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='info']\n  [data-close-button] {\n  background: var(--info-bg);\n  border-color: var(--info-border);\n  color: var(--info-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='warning'] {\n  background: var(--warning-bg);\n  border-color: var(--warning-border);\n  color: var(--warning-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='warning']\n  [data-close-button] {\n  background: var(--warning-bg);\n  border-color: var(--warning-border);\n  color: var(--warning-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='error'] {\n  background: var(--error-bg);\n  border-color: var(--error-border);\n  color: var(--error-text);\n}\n\n[data-rich-colors='true'][data-sonner-toast][data-type='error']\n  [data-close-button] {\n  background: var(--error-bg);\n  border-color: var(--error-border);\n  color: var(--error-text);\n}\n\n.sonner-loading-wrapper {\n  --size: 16px;\n  height: var(--size);\n  width: var(--size);\n  position: absolute;\n  inset: 0;\n  z-index: 10;\n}\n\n.sonner-loading-wrapper[data-visible='false'] {\n  transform-origin: center;\n  animation: sonner-fade-out 0.2s ease forwards;\n}\n\n.sonner-spinner {\n  position: relative;\n  top: 50%;\n  left: 50%;\n  height: var(--size);\n  width: var(--size);\n}\n\n.sonner-loading-bar {\n  animation: sonner-spin 1.2s linear infinite;\n  background: var(--gray11);\n  border-radius: 6px;\n  height: 8%;\n  left: -10%;\n  position: absolute;\n  top: -3.9%;\n  width: 24%;\n}\n\n.sonner-loading-bar:nth-child(1) {\n  animation-delay: -1.2s;\n  transform: rotate(0.0001deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(2) {\n  animation-delay: -1.1s;\n  transform: rotate(30deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(3) {\n  animation-delay: -1s;\n  transform: rotate(60deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(4) {\n  animation-delay: -0.9s;\n  transform: rotate(90deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(5) {\n  animation-delay: -0.8s;\n  transform: rotate(120deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(6) {\n  animation-delay: -0.7s;\n  transform: rotate(150deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(7) {\n  animation-delay: -0.6s;\n  transform: rotate(180deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(8) {\n  animation-delay: -0.5s;\n  transform: rotate(210deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(9) {\n  animation-delay: -0.4s;\n  transform: rotate(240deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(10) {\n  animation-delay: -0.3s;\n  transform: rotate(270deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(11) {\n  animation-delay: -0.2s;\n  transform: rotate(300deg) translate(146%);\n}\n\n.sonner-loading-bar:nth-child(12) {\n  animation-delay: -0.1s;\n  transform: rotate(330deg) translate(146%);\n}\n\n@keyframes sonner-fade-in {\n  0% {\n    opacity: 0;\n    transform: scale(0.8);\n  }\n  100% {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n@keyframes sonner-fade-out {\n  0% {\n    opacity: 1;\n    transform: scale(1);\n  }\n  100% {\n    opacity: 0;\n    transform: scale(0.8);\n  }\n}\n\n@keyframes sonner-spin {\n  0% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0.15;\n  }\n}\n\n@media (prefers-reduced-motion) {\n  [data-sonner-toast],\n  [data-sonner-toast] > *,\n  .sonner-loading-bar {\n    transition: none !important;\n    animation: none !important;\n  }\n}\n\n.sonner-loader {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  transform-origin: center;\n  transition: opacity 200ms, transform 200ms;\n}\n\n.sonner-loader[data-visible='false'] {\n  opacity: 0;\n  transform: scale(0.8) translate(-50%, -50%);\n}\n"
  },
  {
    "path": "libs/copilot/src/ThemeProvider.tsx",
    "content": "import { createContext, useContext, useEffect, useState } from 'react';\n\ntype Theme = 'dark' | 'light' | 'system';\n\ntype ThemeProviderProps = {\n  children: React.ReactNode;\n  defaultTheme?: Theme;\n  storageKey?: string;\n};\n\ntype ThemeProviderState = {\n  theme: Theme;\n  setTheme: (theme: Theme) => void;\n};\n\nconst initialState: ThemeProviderState = {\n  theme: 'system',\n  setTheme: () => null\n};\n\nconst ThemeProviderContext = createContext<ThemeProviderState>(initialState);\n\nfunction applyThemeVariables(variant: 'dark' | 'light') {\n  if (!window.theme) return;\n\n  const variables = window.theme[variant];\n  if (!variables) return;\n\n  const shadowContainer = window.cl_shadowRootElement;\n  if (!shadowContainer) return;\n\n  // Apply new theme variables\n  Object.entries(variables).forEach(([key, value]) => {\n    shadowContainer.style.setProperty(key, value);\n  });\n}\n\nexport function ThemeProvider({\n  children,\n  defaultTheme = 'system',\n  storageKey = 'vite-ui-theme',\n  ...props\n}: ThemeProviderProps) {\n  const [theme, setTheme] = useState<Theme>(\n    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme\n  );\n\n  useEffect(() => {\n    const shadowContainer = window.cl_shadowRootElement;\n    if (!shadowContainer) return;\n\n    // Remove existing theme classes\n    shadowContainer.classList.remove('light', 'dark');\n\n    if (theme === 'system') {\n      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')\n        .matches\n        ? 'dark'\n        : 'light';\n\n      shadowContainer.classList.add(systemTheme);\n      applyThemeVariables(systemTheme);\n      return;\n    }\n\n    shadowContainer.classList.add(theme);\n    applyThemeVariables(theme);\n  }, [theme]);\n\n  // Listen for system theme changes\n  useEffect(() => {\n    if (theme !== 'system') return;\n\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n\n    const handleChange = () => {\n      const shadowContainer = window.cl_shadowRootElement;\n      if (!shadowContainer) return;\n\n      const newTheme = mediaQuery.matches ? 'dark' : 'light';\n      shadowContainer.classList.remove('light', 'dark');\n      shadowContainer.classList.add(newTheme);\n      applyThemeVariables(newTheme);\n    };\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, [theme]);\n\n  const value = {\n    theme,\n    setTheme: (theme: Theme) => {\n      localStorage.setItem(storageKey, theme);\n      setTheme(theme);\n    }\n  };\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n\nexport const useTheme = () => {\n  const context = useContext(ThemeProviderContext);\n\n  if (context === undefined)\n    throw new Error('useTheme must be used within a ThemeProvider');\n\n  const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches\n    ? 'dark'\n    : 'light';\n\n  const variant = context.theme === 'system' ? systemTheme : context.theme;\n\n  return { ...context, variant };\n};\n"
  },
  {
    "path": "libs/copilot/src/api.ts",
    "content": "import { toast } from 'sonner';\n\nimport { ChainlitAPI, ClientError } from '@chainlit/react-client';\n\nexport function makeApiClient(\n  chainlitServer: string,\n  additionalQueryParams: Record<string, string>\n) {\n  const httpEndpoint = chainlitServer;\n\n  const on401 = () => {\n    toast.error('Unauthorized');\n  };\n\n  const onError = (error: ClientError) => {\n    toast.error(error.toString());\n  };\n\n  return new ChainlitAPI(\n    httpEndpoint,\n    'copilot',\n    additionalQueryParams,\n    on401,\n    onError\n  );\n}\n"
  },
  {
    "path": "libs/copilot/src/app.tsx",
    "content": "import { useContext, useEffect, useState } from 'react';\nimport { Toaster } from 'sonner';\nimport { IWidgetConfig } from 'types';\nimport Widget from 'widget';\n\nimport { useTranslation } from '@chainlit/app/src/components/i18n/Translator';\nimport { ChainlitContext, useAuth } from '@chainlit/react-client';\n\nimport { useCopilotInteract } from './hooks/useCopilotInteract';\n\nimport { ThemeProvider } from './ThemeProvider';\nimport {\n  COPILOT_THREAD_CHANGED_EVENT_KEY,\n  CopilotThreadChangedEventParams\n} from './state';\n\ninterface Props {\n  widgetConfig: IWidgetConfig;\n}\n\ndeclare global {\n  interface Window {\n    cl_shadowRootElement: HTMLDivElement;\n    toggleChainlitCopilot: () => void;\n    theme?: {\n      light: Record<string, string>;\n      dark: Record<string, string>;\n    };\n    getChainlitCopilotThreadId: () => string | null;\n    clearChainlitCopilotThreadId: (newThreadId?: string) => void;\n  }\n}\n\nexport default function App({ widgetConfig }: Props) {\n  const { isAuthenticated, data } = useAuth();\n  const apiClient = useContext(ChainlitContext);\n  const { i18n } = useTranslation();\n  const { startNewChat } = useCopilotInteract();\n  const languageInUse = widgetConfig.language || navigator.language || 'en-US';\n  const [authError, setAuthError] = useState<string>();\n  const [fetchError, setFetchError] = useState<string>();\n\n  useEffect(() => {\n    apiClient\n      .get(`/project/translations?language=${languageInUse}`)\n      .then((res) => res.json())\n      .then((data) => {\n        i18n.addResourceBundle(languageInUse, 'translation', data.translation);\n        i18n.changeLanguage(languageInUse);\n      })\n      .catch((err) => {\n        setFetchError(String(err));\n      });\n  }, []);\n\n  const defaultTheme = widgetConfig.theme || data?.default_theme;\n\n  useEffect(() => {\n    if (fetchError) return;\n    if (!isAuthenticated) {\n      if (!widgetConfig.accessToken) {\n        setAuthError('No authentication token provided.');\n      } else {\n        apiClient\n          .jwtAuth(widgetConfig.accessToken)\n          .catch((err) => setAuthError(String(err)));\n      }\n    } else {\n      setAuthError(undefined);\n    }\n  }, [isAuthenticated, apiClient, fetchError, setAuthError]);\n\n  useEffect(() => {\n    const eventListener = (e: Event) => {\n      const customEvent = e as CustomEvent<CopilotThreadChangedEventParams>;\n      startNewChat(customEvent?.detail?.newThreadId);\n    };\n\n    window.addEventListener(COPILOT_THREAD_CHANGED_EVENT_KEY, eventListener);\n\n    return () => {\n      window.removeEventListener(\n        COPILOT_THREAD_CHANGED_EVENT_KEY,\n        eventListener\n      );\n    };\n  }, []);\n\n  return (\n    <ThemeProvider storageKey=\"vite-ui-theme\" defaultTheme={defaultTheme}>\n      <Toaster className=\"toast\" position=\"bottom-center\" />\n      <Widget config={widgetConfig} error={fetchError || authError} />\n    </ThemeProvider>\n  );\n}\n"
  },
  {
    "path": "libs/copilot/src/appWrapper.tsx",
    "content": "import { makeApiClient } from 'api';\nimport { useEffect, useState } from 'react';\nimport { RecoilRoot } from 'recoil';\nimport { IWidgetConfig } from 'types';\n\nimport { i18nSetupLocalization } from '@chainlit/app/src/i18n';\nimport { ChainlitContext } from '@chainlit/react-client';\n\nimport App from './app';\n\ni18nSetupLocalization();\ninterface Props {\n  widgetConfig: IWidgetConfig;\n}\n\nexport default function AppWrapper({ widgetConfig }: Props) {\n  const additionalQueryParams = widgetConfig?.additionalQueryParamsForAPI;\n  const apiClient = makeApiClient(\n    widgetConfig.chainlitServer,\n    additionalQueryParams || {}\n  );\n  const [customThemeLoaded, setCustomThemeLoaded] = useState(false);\n\n  function completeInitialization() {\n    if (widgetConfig.customCssUrl) {\n      const linkEl = document.createElement('link');\n      linkEl.rel = 'stylesheet';\n      linkEl.href = widgetConfig.customCssUrl;\n      window.cl_shadowRootElement.getRootNode().appendChild(linkEl);\n    }\n    setCustomThemeLoaded(true);\n  }\n\n  useEffect(() => {\n    let fontLoaded = false;\n\n    apiClient\n      .get('/public/theme.json')\n      .then(async (res) => {\n        try {\n          const customTheme = await res.json();\n          if (customTheme.custom_fonts?.length) {\n            fontLoaded = true;\n            customTheme.custom_fonts.forEach((href: string) => {\n              const linkEl = document.createElement('link');\n              linkEl.rel = 'stylesheet';\n              linkEl.href = href;\n              window.cl_shadowRootElement.getRootNode().appendChild(linkEl);\n            });\n          }\n          if (customTheme.variables) {\n            window.theme = customTheme.variables;\n          }\n        } finally {\n          // If no custom font, default to Inter\n          if (!fontLoaded) {\n            const linkEl = document.createElement('link');\n            linkEl.rel = 'stylesheet';\n            linkEl.href =\n              'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap';\n            window.cl_shadowRootElement.getRootNode().appendChild(linkEl);\n          }\n          completeInitialization();\n        }\n      })\n      .catch(() => completeInitialization());\n  }, []);\n\n  if (!customThemeLoaded) return null;\n\n  return (\n    <ChainlitContext.Provider value={apiClient}>\n      <RecoilRoot>\n        <App widgetConfig={widgetConfig} />\n      </RecoilRoot>\n    </ChainlitContext.Provider>\n  );\n}\n"
  },
  {
    "path": "libs/copilot/src/chat/body.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useSetRecoilState } from 'recoil';\nimport { toast } from 'sonner';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport Alert from '@chainlit/app/src/components/Alert';\nimport ChatSettingsModal from '@chainlit/app/src/components/ChatSettings';\nimport { ErrorBoundary } from '@chainlit/app/src/components/ErrorBoundary';\nimport { TaskList } from '@chainlit/app/src/components/Tasklist';\nimport ChatFooter from '@chainlit/app/src/components/chat/Footer';\nimport MessagesContainer from '@chainlit/app/src/components/chat/MessagesContainer';\nimport ScrollContainer from '@chainlit/app/src/components/chat/ScrollContainer';\nimport Translator from '@chainlit/app/src/components/i18n/Translator';\nimport { useLayoutMaxWidth } from '@chainlit/app/src/hooks/useLayoutMaxWidth';\nimport { useUpload } from '@chainlit/app/src/hooks/useUpload';\nimport { IAttachment, attachmentsState } from '@chainlit/app/src/state/chat';\nimport {\n  useChatData,\n  useChatInteract,\n  useConfig\n} from '@chainlit/react-client';\n\nimport WelcomeScreen from '@/components/WelcomeScreen';\nimport ElementSideView from 'components/ElementSideView';\n\nconst Chat = () => {\n  const { config } = useConfig();\n  const layoutMaxWidth = useLayoutMaxWidth();\n  const setAttachments = useSetRecoilState(attachmentsState);\n  const autoScrollRef = useRef(true);\n  const { error, disabled, callFn } = useChatData();\n  const { uploadFile } = useChatInteract();\n  const uploadFileRef = useRef(uploadFile);\n\n  const fileSpec = useMemo(\n    () => ({\n      max_size_mb:\n        config?.features?.spontaneous_file_upload?.max_size_mb || 500,\n      max_files: config?.features?.spontaneous_file_upload?.max_files || 20,\n      accept: config?.features?.spontaneous_file_upload?.accept || ['*/*']\n    }),\n    [config]\n  );\n\n  useEffect(() => {\n    if (callFn) {\n      const event = new CustomEvent('chainlit-call-fn', {\n        detail: callFn\n      });\n      window.dispatchEvent(event);\n    }\n  }, [callFn]);\n\n  useEffect(() => {\n    uploadFileRef.current = uploadFile;\n  }, [uploadFile]);\n\n  const onFileUpload = useCallback(\n    (payloads: File[]) => {\n      const attachements: IAttachment[] = payloads.map((file) => {\n        const id = uuidv4();\n\n        const { xhr, promise } = uploadFileRef.current(file, (progress) => {\n          setAttachments((prev) =>\n            prev.map((attachment) => {\n              if (attachment.id === id) {\n                return {\n                  ...attachment,\n                  uploadProgress: progress\n                };\n              }\n              return attachment;\n            })\n          );\n        });\n\n        promise\n          .then((res) => {\n            setAttachments((prev) =>\n              prev.map((attachment) => {\n                if (attachment.id === id) {\n                  return {\n                    ...attachment,\n                    // Update with the server ID\n                    serverId: res.id,\n                    uploaded: true,\n                    uploadProgress: 100,\n                    cancel: undefined\n                  };\n                }\n                return attachment;\n              })\n            );\n          })\n          .catch((error) => {\n            toast.error(`Failed to upload ${file.name}: ${error.message}`);\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          });\n\n        return {\n          id,\n          type: file.type,\n          name: file.name,\n          size: file.size,\n          uploadProgress: 0,\n          cancel: () => {\n            toast.info(`Cancelled upload of ${file.name}`);\n            xhr.abort();\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          },\n          remove: () => {\n            setAttachments((prev) =>\n              prev.filter((attachment) => attachment.id !== id)\n            );\n          }\n        };\n      });\n      setAttachments((prev) => prev.concat(attachements));\n    },\n    [uploadFile]\n  );\n\n  const onFileUploadError = useCallback(\n    (error: string) => toast.error(error),\n    [toast]\n  );\n\n  const upload = useUpload({\n    spec: fileSpec,\n    onResolved: onFileUpload,\n    onError: onFileUploadError,\n    options: { noClick: true }\n  });\n\n  const enableAttachments =\n    !disabled && config?.features?.spontaneous_file_upload?.enabled;\n\n  return (\n    <div\n      {...(enableAttachments\n        ? upload?.getRootProps({ className: 'dropzone' })\n        : {})}\n      // Disable the onFocus and onBlur events in react-dropzone to avoid interfering with child trigger events\n      onBlur={undefined}\n      onFocus={undefined}\n      className=\"flex w-full h-full flex-col overflow-y-auto\"\n    >\n      {upload ? (\n        <input id=\"#upload-drop-input\" {...upload.getInputProps()} />\n      ) : null}\n      <div className=\"flex-grow flex flex-col overflow-y-auto\">\n        {error ? (\n          <div className=\"w-full mx-auto my-2\">\n            <Alert className=\"mx-2\" id=\"session-error\" variant=\"error\">\n              <Translator path=\"common.status.error.serverConnection\" />\n            </Alert>\n          </div>\n        ) : null}\n        <ChatSettingsModal />\n        <ErrorBoundary>\n          <ScrollContainer\n            autoScrollUserMessage={config?.features?.user_message_autoscroll}\n            autoScrollAssistantMessage={\n              config?.features?.assistant_message_autoscroll\n            }\n            autoScrollRef={autoScrollRef}\n          >\n            <div\n              className=\"flex flex-col mx-auto w-full flex-grow px-4 pt-4\"\n              style={{\n                maxWidth: layoutMaxWidth\n              }}\n            >\n              <TaskList isMobile={true} isCopilot />\n              <WelcomeScreen />\n              <MessagesContainer />\n            </div>\n          </ScrollContainer>\n          <div\n            className=\"flex flex-col mx-auto w-full px-4 pb-4\"\n            style={{\n              maxWidth: layoutMaxWidth\n            }}\n          >\n            <ChatFooter\n              showIfEmptyThread\n              fileSpec={fileSpec}\n              onFileUpload={onFileUpload}\n              onFileUploadError={onFileUploadError}\n              autoScrollRef={autoScrollRef}\n            />\n          </div>\n        </ErrorBoundary>\n      </div>\n      <ElementSideView />\n    </div>\n  );\n};\n\nexport default Chat;\n"
  },
  {
    "path": "libs/copilot/src/chat/index.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\n\nimport {\n  threadIdToResumeState,\n  useChatInteract,\n  useChatSession\n} from '@chainlit/react-client';\n\nimport { copilotThreadIdState } from '../state';\nimport ChatBody from './body';\n\nexport default function ChatWrapper() {\n  const { connect, session, idToResume } = useChatSession();\n  const { sendMessage } = useChatInteract();\n  const copilotThreadId = useRecoilValue(copilotThreadIdState);\n  const setThreadIdToResume = useSetRecoilState(threadIdToResumeState);\n  const hasConnected = useRef<boolean>(false);\n  const lastConnectedThreadId = useRef<string | null>(null);\n\n  useEffect(() => {\n    if (!copilotThreadId) {\n      return;\n    }\n\n    setThreadIdToResume(copilotThreadId);\n  }, [copilotThreadId, setThreadIdToResume]);\n\n  useEffect(() => {\n    if (\n      copilotThreadId &&\n      lastConnectedThreadId.current &&\n      copilotThreadId !== lastConnectedThreadId.current &&\n      hasConnected.current\n    ) {\n      if (session?.socket?.connected) {\n        session.socket.disconnect();\n      }\n      hasConnected.current = false;\n      lastConnectedThreadId.current = null;\n    }\n  }, [copilotThreadId]);\n\n  useEffect(() => {\n    if (!copilotThreadId || !idToResume || copilotThreadId !== idToResume) {\n      return;\n    }\n\n    if (hasConnected.current) {\n      return;\n    }\n\n    hasConnected.current = true;\n    lastConnectedThreadId.current = copilotThreadId;\n    connect({\n      // @ts-expect-error window typing\n      transports: window.transports,\n      userEnv: {}\n    });\n  }, [copilotThreadId, idToResume, connect]);\n\n  useEffect(() => {\n    // @ts-expect-error is not a valid prop\n    window.sendChainlitMessage = sendMessage;\n  }, [sendMessage]);\n\n  return <ChatBody />;\n}\n"
  },
  {
    "path": "libs/copilot/src/components/ElementSideView.tsx",
    "content": "import { useRecoilState } from 'recoil';\n\nimport { Element } from '@chainlit/app/src/components/Elements';\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle\n} from '@chainlit/app/src/components/ui/dialog';\nimport { sideViewState } from '@chainlit/react-client';\n\nexport default function ElementSideView() {\n  const [sideView, setSideView] = useRecoilState(sideViewState);\n\n  if (!sideView || sideView.title === 'canvas') return null;\n\n  return (\n    <Dialog open onOpenChange={(open) => !open && setSideView(undefined)}>\n      <DialogContent className=\"max-w-[80%]\">\n        <DialogHeader>\n          <DialogTitle>{sideView.title}</DialogTitle>\n        </DialogHeader>\n        <div\n          className=\"mt-4 overflow-y-auto overscroll-contain min-h-[50vh] max-h-[80vh] flex flex-col gap-4\"\n          onWheel={(e) => e.stopPropagation()}\n        >\n          {sideView.elements.map((e) => (\n            <Element key={e.id} element={e} />\n          ))}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "libs/copilot/src/components/Header.tsx",
    "content": "import { Maximize, Minimize } from 'lucide-react';\n\nimport AudioPresence from '@chainlit/app/src/components/AudioPresence';\nimport { Logo } from '@chainlit/app/src/components/Logo';\nimport ChatProfiles from '@chainlit/app/src/components/header/ChatProfiles';\nimport NewChatButton from '@chainlit/app/src/components/header/NewChat';\nimport { Button } from '@chainlit/app/src/components/ui/button';\nimport { IChainlitConfig, useAudio } from '@chainlit/react-client';\n\nimport { useCopilotInteract } from '../hooks';\n\ninterface IProjectConfig {\n  config?: IChainlitConfig;\n  error?: Error;\n  isLoading: boolean;\n  language: string;\n}\n\ninterface Props {\n  expanded: boolean;\n  setExpanded: (expanded: boolean) => void;\n  projectConfig: IProjectConfig;\n}\n\nconst Header = ({\n  expanded,\n  setExpanded,\n  projectConfig\n}: Props): JSX.Element => {\n  const { config } = projectConfig;\n  const { audioConnection } = useAudio();\n  const { startNewChat } = useCopilotInteract();\n\n  const hasChatProfiles = !!config?.chatProfiles.length;\n\n  return (\n    <div className=\"flex align-center justify-between p-4 pb-0\">\n      <div className=\"flex items-center gap-1\">\n        {hasChatProfiles ? <ChatProfiles /> : <Logo className=\"w-[100px]\" />}\n      </div>\n      <div className=\"flex items-center\">\n        {audioConnection === 'on' ? (\n          <AudioPresence\n            type=\"server\"\n            height={20}\n            width={40}\n            barCount={4}\n            barSpacing={2}\n          />\n        ) : null}\n        <NewChatButton\n          className=\"text-muted-foreground mt-[1.5px]\"\n          onConfirm={startNewChat}\n        />\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          onClick={() => setExpanded(!expanded)}\n        >\n          {expanded ? (\n            <Minimize className=\"!size-5 text-muted-foreground\" />\n          ) : (\n            <Maximize className=\"!size-5 text-muted-foreground\" />\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nexport default Header;\n"
  },
  {
    "path": "libs/copilot/src/components/WelcomeScreen.tsx",
    "content": "import { useEffect, useState } from 'react';\n\nimport Starters from '@chainlit/app/src/components/chat/Starters';\nimport { cn, hasMessage } from '@chainlit/app/src/lib/utils';\nimport { useChatMessages } from '@chainlit/react-client';\n\nexport default function WelcomeScreen() {\n  const { messages } = useChatMessages();\n  const [isVisible, setIsVisible] = useState(false);\n\n  useEffect(() => {\n    setIsVisible(true);\n  }, []);\n\n  if (hasMessage(messages)) return null;\n\n  return (\n    <div\n      className={cn(\n        'flex flex-col pb-4 flex-grow welcome-screen transition-opacity duration-500 opacity-0 delay-100',\n        isVisible && 'opacity-100'\n      )}\n    >\n      <Starters className=\"items-end mt-auto\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "libs/copilot/src/hooks/index.ts",
    "content": "export { useCopilotInteract } from './useCopilotInteract';\n"
  },
  {
    "path": "libs/copilot/src/hooks/useCopilotInteract.ts",
    "content": "import { useCallback } from 'react';\nimport { useSetRecoilState } from 'recoil';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport { useChatInteract } from '@chainlit/react-client';\n\nimport { copilotThreadIdState } from '../state';\n\nexport const useCopilotInteract = () => {\n  const chatInteract = useChatInteract();\n  const setCopilotThreadId = useSetRecoilState(copilotThreadIdState);\n\n  const startNewChat = useCallback(\n    (newThreadId?: string) => {\n      chatInteract.clear();\n      setCopilotThreadId(newThreadId || uuidv4());\n    },\n    [chatInteract, setCopilotThreadId]\n  );\n\n  return {\n    ...chatInteract,\n    clear: startNewChat,\n    startNewChat\n  };\n};\n"
  },
  {
    "path": "libs/copilot/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n#cl-shadow-root {\n  margin: 0;\n  padding: 0;\n  font-family: var(--font-sans);\n  color: hsl(var(--foreground));\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n#cl-shadow-root code {\n  font-family: var(--font-mono);\n}\n\n@layer base {\n  #shadow-root-container {\n    --font-sans: 'Inter', sans-serif;\n    --font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;\n    --background: 0 0% 100%;\n    --foreground: 0 0% 5%;\n    --card: 0 0% 100%;\n    --card-foreground: 0 0% 5%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 0 0% 5%;\n    --primary: 340 92% 52%;\n    --primary-foreground: 0 0% 100%;\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n    --muted: 0 0% 90%;\n    --muted-foreground: 0 0% 36%;\n    --accent: 0 0% 95%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 0 0% 90%;\n    --input: 0 0% 90%;\n    --ring: 340 92% 52%;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n    --radius: 0.75rem;\n    --sidebar-background: 0 0% 98%;\n    --sidebar-foreground: 240 5.3% 26.1%;\n    --sidebar-primary: 240 5.9% 10%;\n    --sidebar-primary-foreground: 0 0% 98%;\n    --sidebar-accent: 240 4.8% 95.9%;\n    --sidebar-accent-foreground: 240 5.9% 10%;\n    --sidebar-border: 220 13% 91%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n  .dark {\n    --background: 0 0% 13%;\n    --foreground: 0 0% 93%;\n    --card: 0 0% 18%;\n    --card-foreground: 210 40% 98%;\n    --popover: 0 0% 18%;\n    --popover-foreground: 210 40% 98%;\n    --primary: 340 92% 52%;\n    --primary-foreground: 0 0% 100%;\n    --secondary: 0 0% 19%;\n    --secondary-foreground: 210 40% 98%;\n    --muted: 0 1% 26%;\n    --muted-foreground: 0 0% 71%;\n    --accent: 0 0% 26%;\n    --accent-foreground: 210 40% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 0 1% 26%;\n    --input: 0 1% 26%;\n    --ring: 340 92% 52%;\n    --chart-1: 220 70% 50%;\n    --chart-2: 160 60% 45%;\n    --chart-3: 30 80% 55%;\n    --chart-4: 280 65% 60%;\n    --chart-5: 340 75% 55%;\n    --radius: 0.75rem;\n    --sidebar-background: 0 0% 9%;\n    --sidebar-foreground: 240 4.8% 95.9%;\n    --sidebar-primary: 224.3 76.3% 48%;\n    --sidebar-primary-foreground: 0 0% 100%;\n    --sidebar-accent: 0 0% 13%;\n    --sidebar-accent-foreground: 240 4.8% 95.9%;\n    --sidebar-border: 240 3.7% 15.9%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n@keyframes loading-shimmer {\n  0% {\n    background-position: -100% top;\n  }\n\n  to {\n    background-position: 250% top;\n  }\n}\n\n#cl-shadow-root .loading-shimmer {\n  background-position: -100% top;\n  text-fill-color: transparent;\n  -webkit-text-fill-color: transparent;\n  animation-delay: 0s;\n  animation-duration: 4s;\n  animation-iteration-count: infinite;\n  animation-name: loading-shimmer;\n  background: hsl(var(--muted))\n    gradient(\n      linear,\n      100% 0,\n      0 0,\n      from(hsl(var(--muted))),\n      color-stop(0.5, hsl(var(--foreground))),\n      to(hsl(var(--muted)))\n    );\n  background: hsl(var(--muted)) -webkit-gradient(linear, 100% 0, 0 0, from(hsl(var(--muted))), color-stop(0.5, hsl(var(--foreground))), to(hsl(var(--muted))));\n  background-clip: text;\n  -webkit-background-clip: text;\n  background-repeat: no-repeat;\n  background-size: 50% 200%;\n}\n"
  },
  {
    "path": "libs/copilot/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "libs/copilot/src/state.ts",
    "content": "import { atom } from 'recoil';\nimport { v4 as uuidv4 } from 'uuid';\n\nconst COPILOT_THREAD_ID_KEY = 'chainlit-copilot-thread-id';\nexport const COPILOT_THREAD_CHANGED_EVENT_KEY =\n  'chainlit-copilot-thread-changed';\nexport class CopilotThreadChangedEventParams {\n  newThreadId?: string;\n}\n\nexport const copilotThreadIdState = atom<string>({\n  key: 'CopilotThreadId',\n  default: '',\n  effects: [\n    ({ setSelf, onSet }) => {\n      const savedValue = localStorage.getItem(COPILOT_THREAD_ID_KEY);\n\n      if (savedValue != null) {\n        try {\n          const parsedValue = JSON.parse(savedValue);\n          setSelf(parsedValue);\n        } catch (_error) {\n          const newThreadId = uuidv4();\n          localStorage.setItem(\n            COPILOT_THREAD_ID_KEY,\n            JSON.stringify(newThreadId)\n          );\n          setSelf(newThreadId);\n        }\n      } else {\n        const newThreadId = uuidv4();\n        localStorage.setItem(\n          COPILOT_THREAD_ID_KEY,\n          JSON.stringify(newThreadId)\n        );\n        setSelf(newThreadId);\n      }\n\n      onSet((newValue, _, isReset) => {\n        if (isReset) {\n          localStorage.removeItem(COPILOT_THREAD_ID_KEY);\n        } else {\n          localStorage.setItem(COPILOT_THREAD_ID_KEY, JSON.stringify(newValue));\n        }\n      });\n    }\n  ]\n});\n\nexport const getChainlitCopilotThreadId = () => {\n  const threadId = localStorage.getItem(COPILOT_THREAD_ID_KEY);\n  return threadId ? JSON.parse(threadId) : null;\n};\n\nexport const clearChainlitCopilotThreadId = (newThreadId?: string) => {\n  window.dispatchEvent(\n    new CustomEvent<CopilotThreadChangedEventParams>(\n      COPILOT_THREAD_CHANGED_EVENT_KEY,\n      {\n        detail: { newThreadId }\n      }\n    )\n  );\n};\n"
  },
  {
    "path": "libs/copilot/src/types.ts",
    "content": "export interface IWidgetConfig {\n  chainlitServer: string;\n  showCot?: boolean;\n  accessToken?: string;\n  theme?: 'light' | 'dark';\n  button?: {\n    containerId?: string;\n    imageUrl?: string;\n    className?: string;\n  };\n  customCssUrl?: string;\n  additionalQueryParamsForAPI?: Record<string, string>;\n  expanded?: boolean;\n  language?: string;\n  opened?: boolean;\n}\n"
  },
  {
    "path": "libs/copilot/src/widget.tsx",
    "content": "import { cn } from '@/lib/utils';\nimport { MessageCircle, X } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\nimport Alert from '@chainlit/app/src/components/Alert';\nimport { Button } from '@chainlit/app/src/components/ui/button';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger\n} from '@chainlit/app/src/components/ui/popover';\nimport { useConfig } from '@chainlit/react-client';\n\nimport Header from './components/Header';\n\nimport ChatWrapper from './chat';\nimport {\n  clearChainlitCopilotThreadId,\n  getChainlitCopilotThreadId\n} from './state';\nimport { IWidgetConfig } from './types';\n\ninterface Props {\n  config: IWidgetConfig;\n  error?: string;\n}\n\nconst Widget = ({ config, error }: Props) => {\n  const [expanded, setExpanded] = useState(config?.expanded || false);\n  const [isOpen, setIsOpen] = useState(config?.opened || false);\n  const projectConfig = useConfig();\n\n  useEffect(() => {\n    window.toggleChainlitCopilot = () => setIsOpen((prev) => !prev);\n    window.getChainlitCopilotThreadId = getChainlitCopilotThreadId;\n    window.clearChainlitCopilotThreadId = clearChainlitCopilotThreadId;\n\n    return () => {\n      window.toggleChainlitCopilot = () => console.error('Widget not mounted.');\n      window.getChainlitCopilotThreadId = () => null;\n\n      window.clearChainlitCopilotThreadId = () =>\n        console.error('Widget not mounted.');\n    };\n  }, []);\n\n  const customClassName = config?.button?.className || '';\n\n  return (\n    <Popover open={isOpen} onOpenChange={setIsOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          id=\"chainlit-copilot-button\"\n          className={cn(\n            'fixed h-16 w-16 rounded-full bottom-8 right-8 z-[20]',\n            'transition-transform duration-300 ease-in-out',\n            customClassName\n          )}\n        >\n          <div className=\"relative w-full h-full flex items-center justify-center\">\n            {config?.button?.imageUrl ? (\n              <img\n                width=\"100%\"\n                src={config.button.imageUrl}\n                alt=\"Chat bubble icon\"\n                className={cn(\n                  'transition-opacity',\n                  isOpen ? 'opacity-0' : 'opacity-100'\n                )}\n              />\n            ) : (\n              <MessageCircle\n                className={cn(\n                  '!size-7 transition-opacity',\n                  isOpen ? 'opacity-0' : 'opacity-100'\n                )}\n              />\n            )}\n            <X\n              className={cn(\n                'absolute !size-7 transition-all',\n                isOpen ? 'rotate-0 scale-100' : 'rotate-90 scale-0'\n              )}\n            />\n          </div>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        onInteractOutside={(e) => {\n          e.preventDefault();\n        }}\n        side=\"top\"\n        align=\"end\"\n        sideOffset={12}\n        className={cn(\n          'flex flex-col p-0',\n          'transition-all duration-300 ease-in-out bg-background',\n          expanded ? 'w-[80vw]' : 'w-[min(400px,80vw)]',\n          'h-[min(730px,calc(100vh-150px))]',\n          'overflow-hidden rounded-xl',\n          'shadow-lg',\n          'z-50',\n          'animate-in fade-in-0 zoom-in-95',\n          'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n          expanded\n            ? 'copilot-container-expanded'\n            : 'copilot-container-collapsed'\n        )}\n      >\n        <div id=\"chainlit-copilot-chat\" className=\"flex flex-col h-full w-full\">\n          {error ? (\n            <Alert variant=\"error\">{error}</Alert>\n          ) : (\n            <>\n              <Header\n                expanded={expanded}\n                setExpanded={setExpanded}\n                projectConfig={projectConfig}\n              />\n              <div className=\"flex flex-grow overflow-y-auto\">\n                <ChatWrapper />\n              </div>\n            </>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport default Widget;\n"
  },
  {
    "path": "libs/copilot/stories/App.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/react';\nimport AppWrapper from 'appWrapper';\n\n// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export\nconst meta = {\n  title: 'Example/App',\n  component: AppWrapper,\n  parameters: {\n    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout\n    layout: 'centered'\n  },\n  // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs\n  tags: ['autodocs'],\n  // More on argTypes: https://storybook.js.org/docs/api/argtypes\n  argTypes: {}\n} satisfies Meta<typeof AppWrapper>;\n\nexport default meta;\ntype Story = StoryObj<typeof meta>;\n\n// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args\nexport const Primary: Story = {\n  args: {\n    widgetConfig: {\n      chainlitServer: 'http://localhost:8000'\n    }\n  }\n};\n\nexport const Secondary: Story = {\n  args: {\n    widgetConfig: {\n      chainlitServer: 'http://localhost:8000',\n      theme: 'dark',\n      button: {\n        imageUrl:\n          'https://steelbluemedia.com/wp-content/uploads/2019/06/new-google-favicon-512.png',\n      }\n    }\n  }\n};\n"
  },
  {
    "path": "libs/copilot/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n    darkMode: [\"class\"],\n    content: [\"./src/**/*.{ts,tsx,js,jsx}\", \"../../frontend/src/**/*.{ts,tsx,js,jsx}\"],\n  \ttheme: {\n  \textend: {\n  \t\tborderRadius: {\n  \t\t\tlg: 'var(--radius)',\n  \t\t\tmd: 'calc(var(--radius) - 2px)',\n  \t\t\tsm: 'calc(var(--radius) - 4px)'\n  \t\t},\n  \t\tcolors: {\n  \t\t\tbackground: 'hsl(var(--background))',\n  \t\t\tforeground: 'hsl(var(--foreground))',\n  \t\t\tcard: {\n  \t\t\t\tDEFAULT: 'hsl(var(--card))',\n  \t\t\t\tforeground: 'hsl(var(--card-foreground))'\n  \t\t\t},\n  \t\t\tpopover: {\n  \t\t\t\tDEFAULT: 'hsl(var(--popover))',\n  \t\t\t\tforeground: 'hsl(var(--popover-foreground))'\n  \t\t\t},\n  \t\t\tprimary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--primary))',\n  \t\t\t\tforeground: 'hsl(var(--primary-foreground))'\n  \t\t\t},\n  \t\t\tsecondary: {\n  \t\t\t\tDEFAULT: 'hsl(var(--secondary))',\n  \t\t\t\tforeground: 'hsl(var(--secondary-foreground))'\n  \t\t\t},\n  \t\t\tmuted: {\n  \t\t\t\tDEFAULT: 'hsl(var(--muted))',\n  \t\t\t\tforeground: 'hsl(var(--muted-foreground))'\n  \t\t\t},\n  \t\t\taccent: {\n  \t\t\t\tDEFAULT: 'hsl(var(--accent))',\n  \t\t\t\tforeground: 'hsl(var(--accent-foreground))'\n  \t\t\t},\n  \t\t\tdestructive: {\n  \t\t\t\tDEFAULT: 'hsl(var(--destructive))',\n  \t\t\t\tforeground: 'hsl(var(--destructive-foreground))'\n  \t\t\t},\n  \t\t\tborder: 'hsl(var(--border))',\n  \t\t\tinput: 'hsl(var(--input))',\n  \t\t\tring: 'hsl(var(--ring))',\n  \t\t\tchart: {\n  \t\t\t\t'1': 'hsl(var(--chart-1))',\n  \t\t\t\t'2': 'hsl(var(--chart-2))',\n  \t\t\t\t'3': 'hsl(var(--chart-3))',\n  \t\t\t\t'4': 'hsl(var(--chart-4))',\n  \t\t\t\t'5': 'hsl(var(--chart-5))'\n  \t\t\t}\n  \t\t},\n\t\t  keyframes: {\n\t\t\t'accordion-down': {\n\t\t\t\tfrom: {\n\t\t\t\t\theight: '0'\n\t\t\t\t},\n\t\t\t\tto: {\n\t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n\t\t\t\t}\n\t\t\t},\n\t\t\t'accordion-up': {\n\t\t\t\tfrom: {\n\t\t\t\t\theight: 'var(--radix-accordion-content-height)'\n\t\t\t\t},\n\t\t\t\tto: {\n\t\t\t\t\theight: '0'\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\tanimation: {\n\t\t\t'accordion-down': 'accordion-down 0.2s ease-out',\n\t\t\t'accordion-up': 'accordion-up 0.2s ease-out'\n\t\t}\n  \t}\n  },\n  // eslint-disable-next-line\n  plugins: [require(\"tailwindcss-animate\")],\n}\n\n"
  },
  {
    "path": "libs/copilot/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\n      \"DOM\",\n      \"DOM.Iterable\",\n      \"ESNext\"\n    ],\n    \"baseUrl\": \"./src\",\n    \"paths\": {\n      \"@/*\": [\n        \"./*\"\n      ]\n    },\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"sourceMap\": true,\n    \"strict\": true,\n    \"strictNullChecks\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"types\": [\n      \"node\"\n    ]\n  },\n  \"include\": [\n    \"./src\",\n    \"./stories\"\n  ],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.node.json\"\n    }\n  ]\n}"
  },
  {
    "path": "libs/copilot/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "libs/copilot/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react-swc';\nimport path from 'path';\nimport { defineConfig } from 'vite';\nimport svgr from 'vite-plugin-svgr';\nimport tsconfigPaths from 'vite-tsconfig-paths';\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [react(), tsconfigPaths(), svgr()],\n  build: {\n    rollupOptions: {\n      input: {\n        copilot: path.resolve(__dirname, 'index.tsx')\n      },\n      output: [\n        {\n          name: 'copilot',\n          dir: 'dist',\n          format: 'iife',\n          entryFileNames: 'index.js',\n          inlineDynamicImports: true\n        }\n      ]\n    }\n  },\n  resolve: {\n    alias: {\n      // To prevent conflicts with packages in @chainlit/app, we need to specify the resolution paths for these dependencies.\n      react: path.resolve(__dirname, './node_modules/react'),\n      '@chainlit': path.resolve(__dirname, './node_modules/@chainlit'),\n      postcss: path.resolve(__dirname, './node_modules/postcss'),\n      tailwindcss: path.resolve(__dirname, './node_modules/tailwindcss'),\n      i18next: path.resolve(__dirname, './node_modules/i18next'),\n      sonner: path.resolve(__dirname, './node_modules/sonner'),\n      'highlight.js': path.resolve(__dirname, './node_modules/highlight.js'),\n      'react-i18next': path.resolve(__dirname, './node_modules/react-i18next'),\n      'usehooks-ts': path.resolve(__dirname, './node_modules/usehooks-ts'),\n      lodash: path.resolve(__dirname, './node_modules/lodash'),\n      recoil: path.resolve(__dirname, './node_modules/recoil')\n    }\n  }\n});\n"
  },
  {
    "path": "libs/react-client/README.md",
    "content": "## Overview\n\nThe `@chainlit/react-client` package provides a set of React hooks as well as an API client to connect to your [Chainlit](https://github.com/Chainlit/chainlit) application from any React application. The package includes hooks for managing chat sessions, messages, data, and interactions.\n\n## Installation\n\nTo install the package, run the following command in your project directory:\n\n```sh\nnpm install @chainlit/react-client\n```\n\nThis package use [Recoil](https://github.com/facebookexperimental/Recoil) to manage its state. This means you will have to wrap your application in a recoil provider:\n\n```tsx\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { RecoilRoot } from 'recoil';\n\nimport { ChainlitAPI, ChainlitContext } from '@chainlit/react-client';\n\nconst CHAINLIT_SERVER_URL = 'http://localhost:8000';\n\nconst apiClient = new ChainlitAPI(CHAINLIT_SERVER_URL, 'webapp');\n\nReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(\n  <React.StrictMode>\n    <ChainlitContext.Provider value={apiClient}>\n      <RecoilRoot>\n        <MyApp />\n      </RecoilRoot>\n    </ChainlitContext.Provider>\n  </React.StrictMode>\n);\n```\n\n## Usage\n\n### `useChatSession`\n\nThis hook is responsible for managing the chat session's connection to the WebSocket server.\n\n#### Methods\n\n- `connect`: Establishes a connection to the WebSocket server.\n- `disconnect`: Disconnects from the WebSocket server.\n- `setChatProfile`: Sets the chat profile state.\n\n#### Example\n\n```jsx\nimport { useChatSession } from '@chainlit/react-client';\n\nconst ChatComponent = () => {\n  const { connect, disconnect, chatProfile, setChatProfile } = useChatSession();\n\n  // Connect to the WebSocket server\n  useEffect(() => {\n    connect({\n      userEnv: {\n        /* user environment variables */\n      }\n    });\n\n    return () => {\n      disconnect();\n    };\n  }, []);\n\n  // Rest of your component logic\n};\n```\n\n### `useChatMessages`\n\nThis hook provides access to the chat messages and the first user message.\n\n#### Properties\n\n- `messages`: An array of chat messages.\n- `firstUserMessage`: The first message from the user.\n\n#### Example\n\n```jsx\nimport { useChatMessages } from '@chainlit/react-client';\n\nconst MessagesComponent = () => {\n  const { messages, firstUserMessage } = useChatMessages();\n\n  // Render your messages\n  return (\n    <div>\n      {messages.map((message) => (\n        <p key={message.id}>{message.output}</p>\n      ))}\n    </div>\n  );\n};\n```\n\n### `useChatData`\n\nThis hook provides access to various chat-related data and states.\n\n#### Properties\n\n- `actions`: An array of actions.\n- `askUser`: The current ask user state.\n- `avatars`: An array of avatar elements.\n- `chatSettingsDefaultValue`: The default value for chat settings.\n- `chatSettingsInputs`: The current chat settings inputs.\n- `chatSettingsValue`: The current value of chat settings.\n- `connected`: A boolean indicating if the WebSocket connection is established.\n- `disabled`: A boolean indicating if the chat is disabled.\n- `elements`: An array of chat elements.\n- `error`: A boolean indicating if there is an error in the session.\n- `loading`: A boolean indicating if the chat is in a loading state.\n- `tasklists`: An array of tasklist elements.\n\n#### Example\n\n```jsx\nimport { useChatData } from '@chainlit/react-client';\n\nconst ChatDataComponent = () => {\n  const { loading, connected, error } = useChatData();\n\n  // Use the data to render your component\n  if (loading) return <p>Loading...</p>;\n  if (error) return <p>Error connecting to chat...</p>;\n  if (!connected) return <p>Disconnected...</p>;\n\n  // Rest of your component logic\n};\n```\n\n### `useChatInteract`\n\nThis hook provides methods to interact with the chat, such as sending messages, replying, and updating settings.\n\n#### Methods\n\n- `callAction`: Calls an action.\n- `clear`: Clears the chat session.\n- `replyMessage`: Replies to a message.\n- `sendMessage`: Sends a message.\n- `stopTask`: Stops the current task.\n- `setIdToResume`: Sets the ID to resume a thread.\n- `updateChatSettings`: Updates the chat settings.\n\n#### Example\n\n```jsx\nimport { useChatInteract } from '@chainlit/react-client';\n\nconst InteractionComponent = () => {\n  const { sendMessage, replyMessage } = useChatInteract();\n\n  const handleSendMessage = () => {\n    const message = { output: 'Hello, World!', id: 'message-id' };\n    sendMessage(message);\n  };\n\n  const handleReplyMessage = () => {\n    const message = { output: 'Replying to your message', id: 'reply-id' };\n    replyMessage(message);\n  };\n\n  // Render your interaction component\n  return (\n    <div>\n      <button onClick={handleSendMessage}>Send Message</button>\n      <button onClick={handleReplyMessage}>Reply to Message</button>\n    </div>\n  );\n};\n```\n"
  },
  {
    "path": "libs/react-client/package.json",
    "content": "{\n  \"name\": \"@chainlit/react-client\",\n  \"description\": \"Websocket client to connect to your chainlit app.\",\n  \"version\": \"0.4.0\",\n  \"scripts\": {\n    \"build\": \"tsup src/index.ts --tsconfig tsconfig.build.json --clean --format esm,cjs --dts  --external react --external recoil --minify --sourcemap --treeshake\",\n    \"dev\": \"tsup src/index.ts --clean --format esm,cjs --dts  --external react --external recoil --minify --sourcemap --treeshake\",\n    \"lint\": \"eslint ./src --ext ts,tsx --report-unused-disable-directives --max-warnings 0 && tsc --noemit\",\n    \"format\": \"prettier '**/*.{ts,tsx}' --write\",\n    \"test\": \"echo no tests yet\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/Chainlit/\"\n  },\n  \"private\": false,\n  \"keywords\": [\n    \"llm\",\n    \"ai\",\n    \"chain of thought\"\n  ],\n  \"author\": \"Chainlit\",\n  \"license\": \"Apache-2.0\",\n  \"files\": [\n    \"dist\",\n    \"README.md\"\n  ],\n  \"main\": \"dist/index.js\",\n  \"module\": \"dist/index.mjs\",\n  \"types\": \"dist/index.d.ts\",\n  \"devDependencies\": {\n    \"@swc/core\": \"^1.3.86\",\n    \"@testing-library/jest-dom\": \"^5.17.0\",\n    \"@testing-library/react\": \"^14.0.0\",\n    \"@types/lodash\": \"^4.14.199\",\n    \"@types/uuid\": \"^9.0.3\",\n    \"@vitejs/plugin-react\": \"^4.0.4\",\n    \"@vitejs/plugin-react-swc\": \"^3.3.2\",\n    \"jsdom\": \"^22.1.0\",\n    \"tslib\": \"^2.6.2\",\n    \"tsup\": \"^7.2.0\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.4.14\",\n    \"vite-tsconfig-paths\": \"^4.2.0\",\n    \"vitest\": \"^0.34.4\"\n  },\n  \"peerDependencies\": {\n    \"@types/react\": \"^18.3.1\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"recoil\": \"^0.7.7\"\n  },\n  \"dependencies\": {\n    \"jwt-decode\": \"^3.1.2\",\n    \"lodash\": \"^4.17.21\",\n    \"socket.io-client\": \"^4.7.2\",\n    \"sonner\": \"^1.7.1\",\n    \"swr\": \"^2.2.2\",\n    \"uuid\": \"^9.0.0\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite@>=4.4.0 <4.4.12\": \">=4.4.12\",\n      \"@adobe/css-tools@<4.3.2\": \">=4.3.2\",\n      \"vite@>=4.0.0 <=4.5.1\": \">=4.5.2\",\n      \"vite@>=4.0.0 <=4.5.2\": \">=4.5.3\",\n      \"braces@<3.0.3\": \">=3.0.3\",\n      \"ws@>=8.0.0 <8.17.1\": \">=8.17.1\",\n      \"micromatch@<4.0.8\": \">=4.0.8\",\n      \"vite@>=4.0.0 <4.5.4\": \">=4.5.4\",\n      \"vite@>=4.0.0 <=4.5.3\": \">=4.5.4\",\n      \"rollup@>=3.0.0 <3.29.5\": \">=3.29.5\",\n      \"cross-spawn@>=7.0.0 <7.0.5\": \">=7.0.5\"\n    }\n  }\n}\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/api.ts",
    "content": "import { useContext, useMemo } from 'react';\nimport { ChainlitAPI } from 'src/api';\nimport { ChainlitContext } from 'src/context';\nimport useSWR, { SWRConfig, SWRConfiguration } from 'swr';\n\nimport { useAuthState } from './auth/state';\n\nconst fetcher = async (client: ChainlitAPI, endpoint: string) => {\n  const res = await client.get(endpoint);\n  return res?.json();\n};\n\nconst cloneClient = (client: ChainlitAPI): ChainlitAPI => {\n  // Shallow clone API client.\n  // TODO: Move me to core API.\n\n  // Create new client\n  const newClient = new ChainlitAPI('', 'webapp');\n\n  // Assign old properties to new client\n  Object.assign(newClient, client);\n\n  return newClient;\n};\n\n/**\n * React hook for cached API data fetching using SWR (stale-while-revalidate).\n * Optimized for GET requests with automatic caching and revalidation.\n *\n * Key features:\n * - Automatic data caching and revalidation\n * - Integration with React component lifecycle\n * - Loading state management\n * - Recoil state integration for global state\n * - Memoized fetcher function to prevent unnecessary rerenders\n *\n * @param path - API endpoint path or null to disable the request\n * @param config - Optional SWR configuration\n * @returns SWR response object containing:\n *          - data: The fetched data\n *          - error: Any error that occurred\n *          - isValidating: Whether a request is in progress\n *          - mutate: Function to mutate the cached data\n *\n * @example\n * const { data, error, isValidating } = useApi<UserData>('/user');\n */\nfunction useApi<T>(\n  path?: string | null,\n  { ...swrConfig }: SWRConfiguration = {}\n) {\n  const client = useContext(ChainlitContext);\n  const { setUser } = useAuthState();\n\n  // Memoize the fetcher function to avoid recreating it on every render\n  const memoizedFetcher = useMemo(\n    () =>\n      ([url]: [url: string]) => {\n        if (!swrConfig.onErrorRetry) {\n          swrConfig.onErrorRetry = (...args) => {\n            const [err] = args;\n\n            // Don't do automatic retry for 401 - it just means we're not logged in (yet).\n            if (err.status === 401) {\n              setUser(null);\n              return;\n            }\n\n            // Fall back to default behavior.\n            return SWRConfig.defaultValue.onErrorRetry(...args);\n          };\n        }\n\n        const useApiClient = cloneClient(client);\n        useApiClient.on401 = useApiClient.onError = undefined;\n        return fetcher(useApiClient, url);\n      },\n    [client]\n  );\n\n  // Use a stable key for useSWR\n  const swrKey = useMemo(() => {\n    return path ? [path] : null;\n  }, [path]);\n\n  return useSWR<T, Error>(swrKey, memoizedFetcher, swrConfig);\n}\n\nexport { useApi, fetcher };\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/config.ts",
    "content": "import { useEffect } from 'react';\nimport { IAuthConfig } from 'src/index';\n\nimport { useApi } from '../api';\nimport { useAuthState } from './state';\n\nexport const useAuthConfig = () => {\n  const { authConfig, setAuthConfig } = useAuthState();\n  const { data: authConfigData, isLoading } = useApi<IAuthConfig>(\n    authConfig ? null : '/auth/config'\n  );\n\n  useEffect(() => {\n    if (authConfigData) {\n      setAuthConfig(authConfigData);\n    }\n  }, [authConfigData, setAuthConfig]);\n\n  return { authConfig, isLoading };\n};\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/index.ts",
    "content": "import { IAuthConfig, IUser } from 'src/types';\n\nimport { useAuthConfig } from './config';\nimport { useSessionManagement } from './sessionManagement';\nimport { useUserManagement } from './userManagement';\n\nexport const useAuth = () => {\n  const { authConfig } = useAuthConfig();\n  const { logout } = useSessionManagement();\n  const { user, setUserFromAPI } = useUserManagement();\n\n  const isReady =\n    !!authConfig && (!authConfig.requireLogin || user !== undefined);\n\n  if (authConfig && !authConfig.requireLogin) {\n    return {\n      data: authConfig,\n      user: null,\n      isReady,\n      isAuthenticated: true,\n      logout: () => Promise.resolve(),\n      setUserFromAPI: () => Promise.resolve()\n    };\n  }\n\n  return {\n    data: authConfig,\n    user,\n    isReady,\n    isAuthenticated: !!user,\n    logout,\n    setUserFromAPI\n  };\n};\n\nexport type { IAuthConfig, IUser };\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/sessionManagement.ts",
    "content": "import { useContext } from 'react';\nimport { ChainlitContext } from 'src/index';\n\nimport { useAuthState } from './state';\n\nexport const useSessionManagement = () => {\n  const apiClient = useContext(ChainlitContext);\n  const { setUser, setThreadHistory } = useAuthState();\n\n  const logout = async (reload = false): Promise<void> => {\n    await apiClient.logout();\n    setUser(undefined);\n    setThreadHistory(undefined);\n\n    if (reload) {\n      window.location.reload();\n    }\n  };\n\n  return { logout };\n};\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/state.ts",
    "content": "import { useRecoilState, useSetRecoilState } from 'recoil';\nimport { authState, threadHistoryState, userState } from 'src/state';\n\nexport const useAuthState = () => {\n  const [authConfig, setAuthConfig] = useRecoilState(authState);\n  const [user, setUser] = useRecoilState(userState);\n  const setThreadHistory = useSetRecoilState(threadHistoryState);\n\n  return {\n    authConfig,\n    setAuthConfig,\n    user,\n    setUser,\n    setThreadHistory\n  };\n};\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/types.ts",
    "content": "import { IAuthConfig, IUser } from 'src/types';\n\nexport interface JWTPayload extends IUser {\n  exp: number;\n}\n\nexport interface AuthState {\n  data: IAuthConfig | undefined;\n  user: IUser | null;\n  isAuthenticated: boolean;\n  isReady: boolean;\n}\n\nexport interface AuthActions {\n  logout: (reload?: boolean) => Promise<void>;\n  setUserFromAPI: () => Promise<void>;\n}\n\nexport type IUseAuth = AuthState & AuthActions;\n"
  },
  {
    "path": "libs/react-client/src/api/hooks/auth/userManagement.ts",
    "content": "import { useEffect } from 'react';\nimport { IUser } from 'src/types';\n\nimport { useApi } from '../api';\nimport { useAuthState } from './state';\n\nexport const useUserManagement = () => {\n  const { user, setUser } = useAuthState();\n\n  const {\n    data: userData,\n    error,\n    mutate: setUserFromAPI\n  } = useApi<IUser>('/user');\n\n  useEffect(() => {\n    if (userData) {\n      setUser(userData);\n    }\n  }, [userData, setUser]);\n\n  useEffect(() => {\n    if (error) {\n      setUser(null);\n    }\n  }, [error]);\n\n  return { user, setUserFromAPI };\n};\n"
  },
  {
    "path": "libs/react-client/src/api/index.tsx",
    "content": "import { IElement, IThread, IUser } from 'src/types';\n\nimport { IAction } from 'src/types/action';\nimport { IFeedback } from 'src/types/feedback';\n\nexport * from './hooks/auth';\nexport * from './hooks/api';\n\nexport interface IThreadFilters {\n  search?: string;\n  feedback?: number;\n}\n\nexport interface IPageInfo {\n  hasNextPage: boolean;\n  endCursor?: string;\n}\n\nexport interface IPagination {\n  first: number;\n  cursor?: string | number;\n}\n\nexport class ClientError extends Error {\n  status: number;\n  detail?: string;\n\n  constructor(message: string, status: number, detail?: string) {\n    super(message);\n    this.status = status;\n    this.detail = detail;\n  }\n\n  toString() {\n    if (this.detail) {\n      return `${this.message}: ${this.detail}`;\n    } else {\n      return this.message;\n    }\n  }\n}\n\ntype Payload = FormData | any;\n\nexport class APIBase {\n  constructor(\n    public httpEndpoint: string,\n    public type: 'webapp' | 'copilot' | 'teams' | 'slack' | 'discord',\n    public additionalQueryParams?: Record<string, string>,\n    public on401?: () => void,\n    public onError?: (error: ClientError) => void\n  ) {}\n\n  buildEndpoint(path: string) {\n    let fullUrl = `${this.httpEndpoint}${path}`;\n    if (this.httpEndpoint.endsWith('/')) {\n      // remove trailing slash on httpEndpoint\n      fullUrl = `${this.httpEndpoint.slice(0, -1)}${path}`;\n    }\n\n    const url = new URL(fullUrl);\n\n    // Add additionalQueryParams for all API calls\n    if (this.additionalQueryParams) {\n      const params = new URLSearchParams(this.additionalQueryParams);\n      const separator = url.search ? '&' : '?';\n      url.search = url.search + `${separator}${params.toString()}`;\n    }\n\n    return url.toString();\n  }\n\n  private async getDetailFromErrorResponse(\n    res: Response\n  ): Promise<string | undefined> {\n    try {\n      const body = await res.json();\n      return body?.detail;\n    } catch (error: any) {\n      console.error('Unable to parse error response', error);\n    }\n    return undefined;\n  }\n\n  private handleRequestError(error: any) {\n    if (error instanceof ClientError) {\n      if (error.status === 401 && this.on401) {\n        this.on401();\n      }\n      if (this.onError) {\n        this.onError(error);\n      }\n    }\n    console.error(error);\n  }\n\n  /**\n   * Low-level HTTP request handler for direct API interactions.\n   * Provides full control over HTTP methods, request configuration, and error handling.\n   *\n   * Key features:\n   * - Supports all HTTP methods (GET, POST, PUT, PATCH, DELETE)\n   * - Handles both FormData and JSON payloads\n   * - Manages authentication headers\n   * - Custom error handling with ClientError class\n   * - Support for request cancellation via AbortSignal\n   *\n   * @param method - HTTP method to use (GET, POST, etc.)\n   * @param path - API endpoint path\n   * @param data - Optional request payload (FormData or JSON-serializable data)\n   * @param signal - Optional AbortSignal for request cancellation\n   * @returns Promise<Response>\n   * @throws ClientError for HTTP errors, including 401 unauthorized\n   */\n  async fetch(\n    method: string,\n    path: string,\n    data?: Payload,\n    signal?: AbortSignal,\n    headers: { Authorization?: string; 'Content-Type'?: string } = {}\n  ): Promise<Response> {\n    try {\n      let body;\n\n      if (data instanceof FormData) {\n        body = data;\n      } else {\n        headers['Content-Type'] = 'application/json';\n        body = data ? JSON.stringify(data) : null;\n      }\n\n      const res = await fetch(this.buildEndpoint(path), {\n        method,\n        credentials: 'include',\n        headers,\n        signal,\n        body\n      });\n\n      if (!res.ok) {\n        const detail = await this.getDetailFromErrorResponse(res);\n\n        throw new ClientError(res.statusText, res.status, detail);\n      }\n\n      return res;\n    } catch (error: any) {\n      this.handleRequestError(error);\n      throw error;\n    }\n  }\n\n  async get(endpoint: string) {\n    return await this.fetch('GET', endpoint);\n  }\n\n  async post(endpoint: string, data: Payload, signal?: AbortSignal) {\n    return await this.fetch('POST', endpoint, data, signal);\n  }\n\n  async put(endpoint: string, data: Payload) {\n    return await this.fetch('PUT', endpoint, data);\n  }\n\n  async patch(endpoint: string, data: Payload) {\n    return await this.fetch('PATCH', endpoint, data);\n  }\n\n  async delete(endpoint: string, data: Payload) {\n    return await this.fetch('DELETE', endpoint, data);\n  }\n}\n\nexport class ChainlitAPI extends APIBase {\n  async headerAuth() {\n    const res = await this.post(`/auth/header`, {});\n    return res.json();\n  }\n\n  async jwtAuth(token: string) {\n    const res = await this.fetch('POST', '/auth/jwt', undefined, undefined, {\n      Authorization: `Bearer ${token}`\n    });\n    return res.json();\n  }\n\n  async stickyCookie(sessionId: string) {\n    const res = await this.fetch('POST', '/set-session-cookie', {\n      session_id: sessionId\n    });\n    return res.json();\n  }\n\n  async passwordAuth(data: FormData) {\n    const res = await this.post(`/login`, data);\n    return res.json();\n  }\n\n  async getUser(): Promise<IUser> {\n    const res = await this.get(`/user`);\n    return res.json();\n  }\n\n  async logout() {\n    const res = await this.post(`/logout`, {});\n    return res.json();\n  }\n\n  async setFeedback(\n    feedback: IFeedback,\n    sessionId: string\n  ): Promise<{ success: boolean; feedbackId: string }> {\n    const res = await this.put(`/feedback`, { feedback, sessionId });\n    return res.json();\n  }\n\n  async deleteFeedback(feedbackId: string): Promise<{ success: boolean }> {\n    const res = await this.delete(`/feedback`, { feedbackId });\n    return res.json();\n  }\n\n  async listThreads(\n    pagination: IPagination,\n    filter: IThreadFilters\n  ): Promise<{\n    pageInfo: IPageInfo;\n    data: IThread[];\n  }> {\n    const res = await this.post(`/project/threads`, { pagination, filter });\n\n    return res.json();\n  }\n\n  async renameThread(threadId: string, name: string) {\n    const res = await this.put(`/project/thread`, { threadId, name });\n\n    return res.json();\n  }\n\n  async deleteThread(threadId: string) {\n    const res = await this.delete(`/project/thread`, { threadId });\n\n    return res.json();\n  }\n\n  uploadFile(\n    file: File,\n    onProgress: (progress: number) => void,\n    sessionId: string,\n    parentId?: string\n  ) {\n    const xhr = new XMLHttpRequest();\n    xhr.withCredentials = true;\n\n    const promise = new Promise<{ id: string }>((resolve, reject) => {\n      const formData = new FormData();\n      formData.append('file', file);\n\n      const ask_parent_id = parentId ? `&ask_parent_id=${parentId}` : '';\n      xhr.open(\n        'POST',\n        this.buildEndpoint(\n          `/project/file?session_id=${sessionId}${ask_parent_id}`\n        ),\n        true\n      );\n\n      // Track the progress of the upload\n      xhr.upload.onprogress = function (event) {\n        if (event.lengthComputable) {\n          const percentage = (event.loaded / event.total) * 100;\n          onProgress(percentage);\n        }\n      };\n\n      xhr.onload = function () {\n        if (xhr.status === 200) {\n          const response = JSON.parse(xhr.responseText);\n          resolve(response);\n          return;\n        }\n        const contentType = xhr.getResponseHeader('Content-Type');\n        if (contentType && contentType.includes('application/json')) {\n          const response = JSON.parse(xhr.responseText);\n          reject(response.detail);\n        } else {\n          reject('Upload failed');\n        }\n      };\n\n      xhr.onerror = function () {\n        reject('Upload error');\n      };\n\n      xhr.send(formData);\n    });\n\n    return { xhr, promise };\n  }\n\n  async callAction(action: IAction, sessionId: string) {\n    const res = await this.post(`/project/action`, { sessionId, action });\n\n    return res.json();\n  }\n\n  async updateElement(element: IElement, sessionId: string) {\n    const res = await this.put(`/project/element`, { sessionId, element });\n\n    return res.json();\n  }\n\n  async deleteElement(element: IElement, sessionId: string) {\n    const res = await this.delete(`/project/element`, { sessionId, element });\n\n    return res.json();\n  }\n\n  async connectStdioMCP(sessionId: string, name: string, fullCommand: string) {\n    const res = await this.post(`/mcp`, {\n      sessionId,\n      name,\n      fullCommand,\n      clientType: 'stdio'\n    });\n    return res.json();\n  }\n\n  async connectSseMCP(\n    sessionId: string,\n    name: string,\n    url: string,\n    headers?: Record<string, string>\n  ) {\n    const res = await this.post(`/mcp`, {\n      sessionId,\n      name,\n      url,\n      ...(headers ? { headers } : {}),\n      clientType: 'sse'\n    });\n    return res.json();\n  }\n\n  async connectStreamableHttpMCP(\n    sessionId: string,\n    name: string,\n    url: string,\n    headers?: Record<string, string>\n  ) {\n    const res = await this.post(`/mcp`, {\n      sessionId,\n      name,\n      url,\n      ...(headers ? { headers } : {}),\n      clientType: 'streamable-http'\n    });\n    return res.json();\n  }\n\n  async disconnectMcp(sessionId: string, name: string) {\n    const res = await this.delete(`/mcp`, { sessionId, name });\n    return res.json();\n  }\n\n  getElementUrl(id: string, sessionId: string) {\n    const queryParams = `?session_id=${sessionId}`;\n    return this.buildEndpoint(`/project/file/${id}${queryParams}`);\n  }\n\n  getLogoEndpoint(theme: string, configuredLogoUrl?: string) {\n    if (configuredLogoUrl) return configuredLogoUrl;\n    return this.buildEndpoint(`/logo?theme=${theme}`);\n  }\n\n  getOAuthEndpoint(provider: string) {\n    return this.buildEndpoint(`/auth/oauth/${provider}`);\n  }\n  async shareThread(threadId: string, isShared: boolean): Promise<{ success: boolean }> {\n    const res = await this.put(`/project/thread/share`, {\n      threadId,\n      isShared\n    });\n    return res.json();\n  }\n}\n"
  },
  {
    "path": "libs/react-client/src/context.ts",
    "content": "import { createContext } from 'react';\n\nimport { ChainlitAPI } from './api';\n\nconst defaultChainlitContext = undefined;\n\nconst ChainlitContext = createContext<ChainlitAPI>(\n  new ChainlitAPI('http://localhost:8000', 'webapp')\n);\n\nexport { ChainlitContext, defaultChainlitContext };\n"
  },
  {
    "path": "libs/react-client/src/index.ts",
    "content": "export * from './useChatData';\nexport * from './useChatInteract';\nexport * from './useChatMessages';\nexport * from './useChatSession';\nexport * from './useAudio';\nexport * from './useConfig';\nexport * from './api';\nexport * from './types';\nexport * from './context';\nexport * from './state';\nexport * from './utils/message';\n\nexport { Socket } from 'socket.io-client';\n\nexport { WavRenderer } from './wavtools/wav_renderer';\n"
  },
  {
    "path": "libs/react-client/src/state.ts",
    "content": "import { isEqual } from 'lodash';\nimport { AtomEffect, DefaultValue, atom, selector } from 'recoil';\nimport { Socket } from 'socket.io-client';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport { ICommand } from './types/command';\nimport { IMode } from './types/mode';\n\nimport {\n  IAction,\n  IAsk,\n  IAuthConfig,\n  ICallFn,\n  IChainlitConfig,\n  IMcp,\n  IMessageElement,\n  IStep,\n  ITasklistElement,\n  IUser,\n  ThreadHistory\n} from './types';\nimport { groupByDate } from './utils/group';\nimport { WavRecorder, WavStreamPlayer } from './wavtools';\n\nexport interface ISession {\n  socket: Socket;\n  error?: boolean;\n}\n\nexport const threadIdToResumeState = atom<string | undefined>({\n  key: 'ThreadIdToResume',\n  default: undefined\n});\n\nexport const resumeThreadErrorState = atom<string | undefined>({\n  key: 'ResumeThreadErrorState',\n  default: undefined\n});\n\nexport const chatProfileState = atom<string | undefined>({\n  key: 'ChatProfile',\n  default: undefined\n});\n\nconst sessionIdAtom = atom<string>({\n  key: 'SessionId',\n  default: uuidv4()\n});\n\nexport const sessionIdState = selector({\n  key: 'SessionIdSelector',\n  get: ({ get }) => get(sessionIdAtom),\n  set: ({ set }, newValue) =>\n    set(sessionIdAtom, newValue instanceof DefaultValue ? uuidv4() : newValue)\n});\n\nexport const sessionState = atom<ISession | undefined>({\n  key: 'Session',\n  dangerouslyAllowMutability: true,\n  default: undefined\n});\n\nexport const actionState = atom<IAction[]>({\n  key: 'Actions',\n  default: []\n});\n\nexport const messagesState = atom<IStep[]>({\n  key: 'Messages',\n  dangerouslyAllowMutability: true,\n  default: []\n});\n\nexport const commandsState = atom<ICommand[]>({\n  key: 'Commands',\n  default: []\n});\n\nexport const modesState = atom<IMode[]>({\n  key: 'Modes',\n  default: []\n});\n\nexport const tokenCountState = atom<number>({\n  key: 'TokenCount',\n  default: 0\n});\n\nexport const loadingState = atom<boolean>({\n  key: 'Loading',\n  default: false\n});\n\nexport const askUserState = atom<IAsk | undefined>({\n  key: 'AskUser',\n  default: undefined\n});\n\nexport const wavRecorderState = atom({\n  key: 'WavRecorder',\n  dangerouslyAllowMutability: true,\n  default: new WavRecorder()\n});\n\nexport const wavStreamPlayerState = atom({\n  key: 'WavStreamPlayer',\n  dangerouslyAllowMutability: true,\n  default: new WavStreamPlayer()\n});\n\nexport const audioConnectionState = atom<'connecting' | 'on' | 'off'>({\n  key: 'AudioConnection',\n  default: 'off'\n});\n\nexport const isAiSpeakingState = atom({\n  key: 'isAiSpeaking',\n  default: false\n});\n\nexport const callFnState = atom<ICallFn | undefined>({\n  key: 'CallFn',\n  default: undefined\n});\n\nexport const chatSettingsInputsState = atom<any>({\n  key: 'ChatSettings',\n  default: []\n});\n\nexport const chatSettingsDefaultValueSelector = selector({\n  key: 'ChatSettingsValue/Default',\n  get: ({ get }) => {\n    const chatSettings = get(chatSettingsInputsState);\n\n    const collectInitialValues = (\n      inputs: any[],\n      acc: Record<string, any>\n    ): Record<string, any> => {\n      if (!Array.isArray(inputs)) {\n        return acc;\n      }\n\n      inputs.forEach((input) => {\n        if (!input) {\n          return;\n        }\n        if (Array.isArray(input?.inputs) && input.inputs.length > 0) {\n          // Handle tabs\n          collectInitialValues(input.inputs, acc);\n        } else if (input?.id !== undefined) {\n          acc[input.id] = input.initial;\n        }\n      });\n\n      return acc;\n    };\n\n    return collectInitialValues(chatSettings, {});\n  }\n});\n\nexport const chatSettingsValueState = atom<Record<string, any>>({\n  key: 'ChatSettingsValue',\n  default: chatSettingsDefaultValueSelector\n});\n\nexport const elementState = atom<IMessageElement[]>({\n  key: 'DisplayElements',\n  default: []\n});\n\nexport const tasklistState = atom<ITasklistElement[]>({\n  key: 'TasklistElements',\n  default: []\n});\n\nexport const firstUserInteraction = atom<string | undefined>({\n  key: 'FirstUserInteraction',\n  default: undefined\n});\n\nexport const userState = atom<IUser | undefined | null>({\n  key: 'User',\n  default: undefined\n});\n\nexport const configState = atom<IChainlitConfig | undefined>({\n  key: 'ChainlitConfig',\n  default: undefined\n});\n\nexport const authState = atom<IAuthConfig | undefined>({\n  key: 'AuthConfig',\n  default: undefined\n});\n\nexport const threadHistoryState = atom<ThreadHistory | undefined>({\n  key: 'ThreadHistory',\n  default: {\n    threads: undefined,\n    currentThreadId: undefined,\n    timeGroupedThreads: undefined,\n    pageInfo: undefined\n  },\n  effects: [\n    ({ setSelf, onSet }: { setSelf: any; onSet: any }) => {\n      onSet(\n        (\n          newValue: ThreadHistory | undefined,\n          oldValue: ThreadHistory | undefined\n        ) => {\n          let timeGroupedThreads = newValue?.timeGroupedThreads;\n          if (\n            newValue?.threads &&\n            !isEqual(newValue.threads, oldValue?.timeGroupedThreads)\n          ) {\n            timeGroupedThreads = groupByDate(newValue.threads);\n          }\n\n          setSelf({\n            ...newValue,\n            timeGroupedThreads\n          });\n        }\n      );\n    }\n  ]\n});\n\nexport const sideViewState = atom<\n  { title: string; elements: IMessageElement[]; key?: string } | undefined\n>({\n  key: 'SideView',\n  default: undefined\n});\n\nexport const currentThreadIdState = atom<string | undefined>({\n  key: 'CurrentThreadId',\n  default: undefined\n});\n\nconst localStorageEffect =\n  <T>(key: string): AtomEffect<T> =>\n    ({ setSelf, onSet }) => {\n      // When the atom is first initialized, try to get its value from localStorage\n      const savedValue = localStorage.getItem(key);\n      if (savedValue != null) {\n        try {\n          setSelf(JSON.parse(savedValue));\n        } catch (error) {\n          console.error(\n            `Error parsing localStorage value for key \"${key}\":`,\n            error\n          );\n        }\n      }\n\n      // Subscribe to state changes and update localStorage\n      onSet((newValue, _, isReset) => {\n        if (isReset) {\n          localStorage.removeItem(key);\n        } else {\n          localStorage.setItem(key, JSON.stringify(newValue));\n        }\n      });\n    };\n\nexport const mcpState = atom<IMcp[]>({\n  key: 'Mcp',\n  default: [],\n  effects: [localStorageEffect<IMcp[]>('mcp_storage_key')]\n});\n\nexport const favoriteMessagesState = atom<IStep[]>({\n  key: 'favoriteMessagesState',\n  default: []\n});\n"
  },
  {
    "path": "libs/react-client/src/types/action.ts",
    "content": "export interface IAction {\n  label: string;\n  forId: string;\n  id: string;\n  payload: Record<string, unknown>;\n  name: string;\n  onClick: () => void;\n  tooltip: string;\n  icon?: string;\n}\n\nexport interface ICallFn {\n  callback: (payload: Record<string, any>) => void;\n  name: string;\n  args: Record<string, any>;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/audio.ts",
    "content": "export interface OutputAudioChunk {\n  track: string;\n  mimeType: string;\n  data: Int16Array;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/command.ts",
    "content": "export interface ICommand {\n  id: string;\n  icon: string;\n  description: string;\n  button?: boolean;\n  persistent?: boolean;\n  selected?: boolean;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/config.ts",
    "content": "export interface IStarter {\n  label: string;\n  message: string;\n  icon?: string;\n  command?: string;\n}\n\nexport interface IStarterCategory {\n  label: string;\n  icon?: string;\n  starters: IStarter[];\n}\n\nexport interface ChatProfile {\n  default: boolean;\n  icon?: string;\n  name: string;\n  display_name?: string;\n  markdown_description: string;\n  starters?: IStarter[];\n}\n\nexport interface IAudioConfig {\n  enabled: boolean;\n  sample_rate: number;\n}\n\nexport interface IAuthConfig {\n  requireLogin: boolean;\n  passwordAuth: boolean;\n  headerAuth: boolean;\n  oauthProviders: string[];\n  default_theme?: 'light' | 'dark';\n  ui?: IChainlitConfig['ui'];\n}\n\nexport interface IChainlitConfig {\n  markdown?: string;\n  ui: {\n    name: string;\n    description?: string;\n    default_theme?: 'light' | 'dark';\n    layout?: 'default' | 'wide';\n    default_sidebar_state?: 'open' | 'closed' | 'hidden';\n    chat_settings_location?: 'message_composer' | 'sidebar';\n    default_chat_settings_open?: boolean;\n    confirm_new_chat?: boolean;\n    cot: 'hidden' | 'tool_call' | 'full';\n    github?: string;\n    custom_css?: string;\n    custom_js?: string;\n    custom_font?: string;\n    alert_style?: 'classic' | 'modern';\n    login_page_image?: string;\n    login_page_image_filter?: string;\n    login_page_image_dark_filter?: string;\n    custom_meta_image_url?: string;\n    logo_file_url?: string;\n    default_avatar_file_url?: string;\n    avatar_size?: number;\n    header_links?: {\n      name: string;\n      display_name: string;\n      icon_url: string;\n      url: string;\n      target?: '_blank' | '_self' | '_parent' | '_top';\n    }[];\n  };\n  features: {\n    spontaneous_file_upload?: {\n      enabled?: boolean;\n      max_size_mb?: number;\n      max_files?: number;\n      accept?: string[] | Record<string, string[]>;\n    };\n    audio: IAudioConfig;\n    unsafe_allow_html?: boolean;\n    user_message_autoscroll?: boolean;\n    assistant_message_autoscroll?: boolean;\n    latex?: boolean;\n    user_message_markdown?: boolean;\n    edit_message?: boolean;\n    favorites?: boolean;\n    mcp?: {\n      enabled?: boolean;\n      sse?: {\n        enabled?: boolean;\n      };\n      streamable_http?: {\n        enabled?: boolean;\n      };\n      stdio?: {\n        enabled?: boolean;\n      };\n    };\n  };\n  debugUrl?: string;\n  userEnv: string[];\n  maskUserEnv?: boolean;\n  dataPersistence: boolean;\n  threadResumable: boolean;\n  threadSharing?: boolean;\n  chatProfiles: ChatProfile[];\n  starters?: IStarter[];\n  starterCategories?: IStarterCategory[];\n\n  translation: object;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/element.ts",
    "content": "export type IElement =\n  | IImageElement\n  | ITextElement\n  | IPdfElement\n  | ITasklistElement\n  | IAudioElement\n  | IVideoElement\n  | IFileElement\n  | IPlotlyElement\n  | IDataframeElement\n  | ICustomElement;\n\nexport type IMessageElement =\n  | IImageElement\n  | ITextElement\n  | IPdfElement\n  | IAudioElement\n  | IVideoElement\n  | IFileElement\n  | IPlotlyElement\n  | IDataframeElement\n  | ICustomElement;\n\nexport type ElementType = IElement['type'];\nexport type IElementSize = 'small' | 'medium' | 'large';\n\ninterface TElement<T> {\n  id: string;\n  type: T;\n  threadId?: string;\n  forId: string;\n  mime?: string;\n  url?: string;\n  chainlitKey?: string;\n}\n\ninterface TMessageElement<T> extends TElement<T> {\n  name: string;\n  display: 'inline' | 'side' | 'page';\n}\n\nexport interface IImageElement extends TMessageElement<'image'> {\n  size?: IElementSize;\n}\n\nexport interface ITextElement extends TMessageElement<'text'> {\n  language?: string;\n}\n\nexport interface IPdfElement extends TMessageElement<'pdf'> {\n  page?: number;\n}\n\nexport interface IAudioElement extends TMessageElement<'audio'> {\n  autoPlay?: boolean;\n}\n\nexport interface IVideoElement extends TMessageElement<'video'> {\n  size?: IElementSize;\n\n  /**\n   * Override settings for each type of player in ReactPlayer\n   * https://github.com/cookpete/react-player?tab=readme-ov-file#config-prop\n   * @type {object}\n   */\n  playerConfig?: object;\n}\n\nexport interface IFileElement extends TMessageElement<'file'> {\n  type: 'file';\n}\n\nexport type IPlotlyElement = TMessageElement<'plotly'>;\n\nexport type ITasklistElement = TElement<'tasklist'>;\n\nexport type IDataframeElement = TMessageElement<'dataframe'>;\n\nexport interface ICustomElement extends TMessageElement<'custom'> {\n  props: Record<string, unknown>;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/feedback.ts",
    "content": "export interface IFeedback {\n  id?: string;\n  forId?: string;\n  threadId?: string;\n  comment?: string;\n  value: number;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/file.ts",
    "content": "import { IAction } from './action';\nimport { IStep } from './step';\n\nexport interface IAskElementResponse {\n  submitted: boolean;\n  [key: string]: unknown;\n}\n\nexport interface FileSpec {\n  accept?: string[] | Record<string, string[]>;\n  max_size_mb?: number;\n  max_files?: number;\n}\n\nexport interface ActionSpec {\n  keys?: string[];\n}\n\nexport interface IFileRef {\n  id: string;\n}\n\nexport interface IAsk {\n  callback: (\n    payload: IStep | IFileRef[] | IAction | IAskElementResponse\n  ) => void;\n  spec: {\n    type: 'text' | 'file' | 'action' | 'element';\n    step_id: string;\n    timeout: number;\n    element_id?: string;\n  } & FileSpec &\n    ActionSpec;\n  parentId?: string;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/history.ts",
    "content": "import { IThread } from 'src/types';\n\nimport { IPageInfo } from '..';\n\nexport type UserInput = {\n  content: string;\n  createdAt: number;\n};\n\nexport type ThreadHistory = {\n  threads?: IThread[];\n  currentThreadId?: string;\n  timeGroupedThreads?: { [key: string]: IThread[] };\n  pageInfo?: IPageInfo;\n};\n"
  },
  {
    "path": "libs/react-client/src/types/index.ts",
    "content": "export * from './action';\nexport * from './element';\nexport * from './command';\nexport * from './mode';\nexport * from './file';\nexport * from './feedback';\nexport * from './step';\nexport * from './user';\nexport * from './thread';\nexport * from './history';\nexport * from './config';\nexport * from './mcp';\n"
  },
  {
    "path": "libs/react-client/src/types/mcp.ts",
    "content": "export interface IMcp {\n  name: string;\n  tools: [{ name: string }];\n  status: 'connected' | 'connecting' | 'failed';\n  clientType: 'sse' | 'stdio' | 'streamable-http';\n  command?: string;\n  url?: string;\n  /** Optional HTTP headers used when connecting (SSE or streamable-http) */\n  headers?: Record<string, string>;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/mode.ts",
    "content": "/**\n * Represents a single selectable option within a mode.\n */\nexport interface IModeOption {\n    id: string;\n    name: string;\n    description?: string;\n    icon?: string;\n    default?: boolean;\n}\n\n/**\n * Represents a mode category containing multiple selectable options.\n * Examples: Model selection, Reasoning Effort, Approach preference, etc.\n */\nexport interface IMode {\n    id: string;\n    name: string;\n    options: IModeOption[];\n}\n"
  },
  {
    "path": "libs/react-client/src/types/step.ts",
    "content": "import { IFeedback } from './feedback';\n\ntype StepType =\n  | 'assistant_message'\n  | 'user_message'\n  | 'system_message'\n  | 'run'\n  | 'tool'\n  | 'llm'\n  | 'embedding'\n  | 'retrieval'\n  | 'rerank'\n  | 'undefined';\n\nexport interface IStep {\n  id: string;\n  name: string;\n  type: StepType;\n  threadId?: string;\n  parentId?: string;\n  isError?: boolean;\n  command?: string;\n  modes?: Record<string, string>;\n  showInput?: boolean | string;\n  waitForAnswer?: boolean;\n  input?: string;\n  output: string;\n  createdAt: number | string;\n  start?: number | string;\n  end?: number | string;\n  feedback?: IFeedback;\n  language?: string;\n  defaultOpen?: boolean;\n  autoCollapse?: boolean;\n  streaming?: boolean;\n  steps?: IStep[];\n  metadata?: Record<string, any>;\n  //legacy\n  indent?: number;\n}\n"
  },
  {
    "path": "libs/react-client/src/types/thread.ts",
    "content": "import { IElement } from './element';\nimport { IStep } from './step';\n\nexport interface IThread {\n  id: string;\n  createdAt: number | string;\n  name?: string;\n  userId?: string;\n  userIdentifier?: string;\n  metadata?: Record<string, any>;\n  steps: IStep[];\n  elements?: IElement[];\n}\n"
  },
  {
    "path": "libs/react-client/src/types/user.ts",
    "content": "export type AuthProvider =\n  | 'credentials'\n  | 'header'\n  | 'github'\n  | 'google'\n  | 'azure-ad'\n  | 'azure-ad-hybrid';\n\nexport interface IUserMetadata extends Record<string, any> {\n  tags?: string[];\n  image?: string;\n  provider?: AuthProvider;\n}\n\nexport interface IUser {\n  id: string;\n  identifier: string;\n  display_name?: string;\n  metadata: IUserMetadata;\n}\n"
  },
  {
    "path": "libs/react-client/src/useAudio.ts",
    "content": "import { useCallback } from 'react';\nimport { useRecoilState, useRecoilValue } from 'recoil';\n\nimport {\n  audioConnectionState,\n  isAiSpeakingState,\n  wavRecorderState,\n  wavStreamPlayerState\n} from './state';\nimport { useChatInteract } from './useChatInteract';\n\nconst useAudio = () => {\n  const [audioConnection, setAudioConnection] =\n    useRecoilState(audioConnectionState);\n  const wavRecorder = useRecoilValue(wavRecorderState);\n  const wavStreamPlayer = useRecoilValue(wavStreamPlayerState);\n  const isAiSpeaking = useRecoilValue(isAiSpeakingState);\n\n  const { startAudioStream, endAudioStream } = useChatInteract();\n\n  const startConversation = useCallback(async () => {\n    setAudioConnection('connecting');\n    await startAudioStream();\n  }, [startAudioStream]);\n\n  const endConversation = useCallback(async () => {\n    setAudioConnection('off');\n    await wavRecorder.end();\n    await wavStreamPlayer.interrupt();\n    await endAudioStream();\n  }, [endAudioStream, wavRecorder, wavStreamPlayer]);\n\n  return {\n    startConversation,\n    endConversation,\n    audioConnection,\n    isAiSpeaking,\n    wavRecorder,\n    wavStreamPlayer\n  };\n};\n\nexport { useAudio };\n"
  },
  {
    "path": "libs/react-client/src/useChatData.ts",
    "content": "import { useRecoilValue } from 'recoil';\n\nimport {\n  actionState,\n  askUserState,\n  callFnState,\n  chatSettingsDefaultValueSelector,\n  chatSettingsInputsState,\n  chatSettingsValueState,\n  elementState,\n  loadingState,\n  sessionState,\n  tasklistState\n} from './state';\n\nexport interface IToken {\n  id: number | string;\n  token: string;\n  isSequence: boolean;\n  isInput: boolean;\n}\n\nconst useChatData = () => {\n  const loading = useRecoilValue(loadingState);\n  const elements = useRecoilValue(elementState);\n  const tasklists = useRecoilValue(tasklistState);\n  const actions = useRecoilValue(actionState);\n  const session = useRecoilValue(sessionState);\n  const askUser = useRecoilValue(askUserState);\n  const callFn = useRecoilValue(callFnState);\n  const chatSettingsInputs = useRecoilValue(chatSettingsInputsState);\n  const chatSettingsValue = useRecoilValue(chatSettingsValueState);\n  const chatSettingsDefaultValue = useRecoilValue(\n    chatSettingsDefaultValueSelector\n  );\n\n  const connected = session?.socket.connected && !session?.error;\n  const disabled =\n    !connected ||\n    loading ||\n    askUser?.spec.type === 'file' ||\n    askUser?.spec.type === 'action' ||\n    askUser?.spec.type === 'element';\n\n  return {\n    actions,\n    askUser,\n    callFn,\n    chatSettingsDefaultValue,\n    chatSettingsInputs,\n    chatSettingsValue,\n    connected,\n    disabled,\n    elements,\n    error: session?.error,\n    loading,\n    tasklists\n  };\n};\n\nexport { useChatData };\n"
  },
  {
    "path": "libs/react-client/src/useChatInteract.ts",
    "content": "import { useCallback, useContext } from 'react';\nimport { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';\nimport {\n  actionState,\n  askUserState,\n  chatSettingsInputsState,\n  chatSettingsValueState,\n  currentThreadIdState,\n  elementState,\n  favoriteMessagesState,\n  firstUserInteraction,\n  loadingState,\n  messagesState,\n  sessionIdState,\n  sessionState,\n  sideViewState,\n  tasklistState,\n  threadIdToResumeState,\n  tokenCountState\n} from 'src/state';\nimport { IFileRef, IStep } from 'src/types';\nimport { addMessage } from 'src/utils/message';\nimport { v4 as uuidv4 } from 'uuid';\n\nimport { ChainlitContext } from './context';\n\ntype PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;\n\nconst useChatInteract = () => {\n  const client = useContext(ChainlitContext);\n  const session = useRecoilValue(sessionState);\n  const askUser = useRecoilValue(askUserState);\n  const sessionId = useRecoilValue(sessionIdState);\n\n  const resetChatSettings = useResetRecoilState(chatSettingsInputsState);\n  const resetSessionId = useResetRecoilState(sessionIdState);\n  const resetChatSettingsValue = useResetRecoilState(chatSettingsValueState);\n\n  const setFirstUserInteraction = useSetRecoilState(firstUserInteraction);\n  const setLoading = useSetRecoilState(loadingState);\n  const setMessages = useSetRecoilState(messagesState);\n  const setElements = useSetRecoilState(elementState);\n  const setTasklists = useSetRecoilState(tasklistState);\n  const setActions = useSetRecoilState(actionState);\n  const setTokenCount = useSetRecoilState(tokenCountState);\n  const setIdToResume = useSetRecoilState(threadIdToResumeState);\n  const setSideView = useSetRecoilState(sideViewState);\n  const setCurrentThreadId = useSetRecoilState(currentThreadIdState);\n  const setFavoriteMessages = useSetRecoilState(favoriteMessagesState);\n\n  const clear = useCallback(() => {\n    session?.socket.emit('clear_session');\n    session?.socket.disconnect();\n    setIdToResume(undefined);\n    resetSessionId();\n    setFirstUserInteraction(undefined);\n    setMessages([]);\n    setElements([]);\n    setTasklists([]);\n    setActions([]);\n    setTokenCount(0);\n    resetChatSettings();\n    resetChatSettingsValue();\n    setSideView(undefined);\n    setCurrentThreadId(undefined);\n  }, [session]);\n\n  const sendMessage = useCallback(\n    (\n      message: PartialBy<IStep, 'createdAt' | 'id'>,\n      fileReferences: IFileRef[] = []\n    ) => {\n      if (!message.id) {\n        message.id = uuidv4();\n      }\n      if (!message.createdAt) {\n        message.createdAt = new Date().toISOString();\n      }\n      setMessages((oldMessages) => addMessage(oldMessages, message as IStep));\n\n      session?.socket.emit('client_message', { message, fileReferences });\n    },\n    [session?.socket]\n  );\n\n  const editMessage = useCallback(\n    (message: IStep) => {\n      session?.socket.emit('edit_message', { message });\n    },\n    [session?.socket]\n  );\n\n  const toggleMessageFavorite = useCallback(\n    (message: IStep) => {\n      const favorite = !(message.metadata?.favorite ?? false);\n      const nextMessage: IStep = {\n        ...message,\n        metadata: {\n          ...(message.metadata || {}),\n          favorite\n        }\n      };\n\n      setMessages((oldMessages) =>\n        oldMessages.map((item) => (item.id === message.id ? nextMessage : item))\n      );\n\n      setFavoriteMessages((oldFavorites) => {\n        if (favorite) {\n          const filtered = oldFavorites.filter(\n            (step) => step.id !== message.id\n          );\n          return [nextMessage, ...filtered];\n        }\n        return oldFavorites.filter((step) => step.id !== message.id);\n      });\n\n      session?.socket.emit('message_favorite', { message: nextMessage });\n    },\n    [session?.socket, setFavoriteMessages, setMessages]\n  );\n\n  const windowMessage = useCallback(\n    (data: any) => {\n      session?.socket.emit('window_message', data);\n    },\n    [session?.socket]\n  );\n\n  const startAudioStream = useCallback(() => {\n    session?.socket.emit('audio_start');\n  }, [session?.socket]);\n\n  const sendAudioChunk = useCallback(\n    (\n      isStart: boolean,\n      mimeType: string,\n      elapsedTime: number,\n      data: Int16Array\n    ) => {\n      session?.socket.emit('audio_chunk', {\n        isStart,\n        mimeType,\n        elapsedTime,\n        data\n      });\n    },\n    [session?.socket]\n  );\n\n  const endAudioStream = useCallback(() => {\n    session?.socket.emit('audio_end');\n  }, [session?.socket]);\n\n  const replyMessage = useCallback(\n    (message: IStep) => {\n      if (askUser) {\n        if (askUser.parentId) message.parentId = askUser.parentId;\n        setMessages((oldMessages) => addMessage(oldMessages, message));\n        askUser.callback(message);\n      }\n    },\n    [askUser]\n  );\n\n  const updateChatSettings = useCallback(\n    (values: object) => {\n      session?.socket.emit('chat_settings_change', values);\n    },\n    [session?.socket]\n  );\n\n  const editChatSettings = useCallback(\n    (values: object) => {\n      session?.socket.emit('chat_settings_edit', values);\n    },\n    [session?.socket]\n  );\n\n  const stopTask = useCallback(() => {\n    setMessages((oldMessages) =>\n      oldMessages.map((m) => {\n        m.streaming = false;\n        return m;\n      })\n    );\n\n    setLoading(false);\n\n    session?.socket.emit('stop');\n  }, [session?.socket]);\n\n  const uploadFile = useCallback(\n    (file: File, onProgress: (progress: number) => void, parentId?: string) => {\n      return client.uploadFile(file, onProgress, sessionId, parentId);\n    },\n    [sessionId]\n  );\n\n  return {\n    uploadFile,\n    clear,\n    replyMessage,\n    sendMessage,\n    editMessage,\n    windowMessage,\n    startAudioStream,\n    sendAudioChunk,\n    endAudioStream,\n    stopTask,\n    setIdToResume,\n    updateChatSettings,\n    editChatSettings,\n    toggleMessageFavorite\n  };\n};\n\nexport { useChatInteract };\n"
  },
  {
    "path": "libs/react-client/src/useChatMessages.ts",
    "content": "import { useRecoilValue } from 'recoil';\n\nimport {\n  currentThreadIdState,\n  firstUserInteraction,\n  messagesState\n} from './state';\n\nconst useChatMessages = () => {\n  const messages = useRecoilValue(messagesState);\n  const firstInteraction = useRecoilValue(firstUserInteraction);\n  const threadId = useRecoilValue(currentThreadIdState);\n\n  return {\n    threadId,\n    messages,\n    firstInteraction\n  };\n};\n\nexport { useChatMessages };\n"
  },
  {
    "path": "libs/react-client/src/useChatSession.ts",
    "content": "import { debounce } from 'lodash';\nimport { useCallback, useContext, useEffect } from 'react';\nimport {\n  useRecoilState,\n  useRecoilValue,\n  useResetRecoilState,\n  useSetRecoilState\n} from 'recoil';\nimport io from 'socket.io-client';\nimport { toast } from 'sonner';\nimport {\n  actionState,\n  askUserState,\n  audioConnectionState,\n  callFnState,\n  chatProfileState,\n  chatSettingsInputsState,\n  chatSettingsValueState,\n  commandsState,\n  currentThreadIdState,\n  elementState,\n  favoriteMessagesState,\n  firstUserInteraction,\n  isAiSpeakingState,\n  loadingState,\n  mcpState,\n  messagesState,\n  modesState,\n  resumeThreadErrorState,\n  sessionIdState,\n  sessionState,\n  sideViewState,\n  tasklistState,\n  threadIdToResumeState,\n  tokenCountState,\n  wavRecorderState,\n  wavStreamPlayerState\n} from 'src/state';\nimport {\n  IAction,\n  ICommand,\n  IElement,\n  IMessageElement,\n  IMode,\n  IStep,\n  ITasklistElement,\n  IThread\n} from 'src/types';\nimport {\n  addMessage,\n  deleteMessageById,\n  updateMessageById,\n  updateMessageContentById\n} from 'src/utils/message';\n\nimport { OutputAudioChunk } from './types/audio';\n\nimport { ChainlitContext } from './context';\nimport type { IToken } from './useChatData';\n\nconst useChatSession = () => {\n  const client = useContext(ChainlitContext);\n  const sessionId = useRecoilValue(sessionIdState);\n\n  const [session, setSession] = useRecoilState(sessionState);\n  const setIsAiSpeaking = useSetRecoilState(isAiSpeakingState);\n  const setAudioConnection = useSetRecoilState(audioConnectionState);\n  const resetChatSettingsValue = useResetRecoilState(chatSettingsValueState);\n  const setChatSettingsValue = useSetRecoilState(chatSettingsValueState);\n  const setFirstUserInteraction = useSetRecoilState(firstUserInteraction);\n  const setLoading = useSetRecoilState(loadingState);\n  const setMcps = useSetRecoilState(mcpState);\n  const wavStreamPlayer = useRecoilValue(wavStreamPlayerState);\n  const wavRecorder = useRecoilValue(wavRecorderState);\n  const setMessages = useSetRecoilState(messagesState);\n  const setAskUser = useSetRecoilState(askUserState);\n  const setCallFn = useSetRecoilState(callFnState);\n  const setCommands = useSetRecoilState(commandsState);\n  const setModes = useSetRecoilState(modesState);\n  const setSideView = useSetRecoilState(sideViewState);\n  const setElements = useSetRecoilState(elementState);\n  const setTasklists = useSetRecoilState(tasklistState);\n  const setActions = useSetRecoilState(actionState);\n  const setChatSettingsInputs = useSetRecoilState(chatSettingsInputsState);\n  const setTokenCount = useSetRecoilState(tokenCountState);\n  const [chatProfile, setChatProfile] = useRecoilState(chatProfileState);\n  const idToResume = useRecoilValue(threadIdToResumeState);\n  const setThreadResumeError = useSetRecoilState(resumeThreadErrorState);\n  const setFavoriteMessages = useSetRecoilState(favoriteMessagesState);\n\n  const [currentThreadId, setCurrentThreadId] =\n    useRecoilState(currentThreadIdState);\n\n  // Use currentThreadId as thread id in websocket header\n  useEffect(() => {\n    if (session?.socket) {\n      session.socket.auth['threadId'] = currentThreadId || '';\n    }\n  }, [currentThreadId]);\n\n  const _connect = useCallback(\n    async ({\n      transports,\n      userEnv\n    }: {\n      transports?: string[];\n      userEnv: Record<string, string>;\n    }) => {\n      const { protocol, host, pathname } = new URL(client.httpEndpoint);\n      const uri = `${protocol}//${host}`;\n      const path =\n        pathname && pathname !== '/'\n          ? `${pathname}/ws/socket.io`\n          : '/ws/socket.io';\n\n      try {\n        await client.stickyCookie(sessionId);\n      } catch (err) {\n        console.error(`Failed to set sticky session cookie: ${err}`);\n      }\n\n      const socket = io(uri, {\n        path,\n        withCredentials: true,\n        transports,\n        auth: {\n          clientType: client.type,\n          sessionId,\n          threadId: idToResume || '',\n          userEnv: JSON.stringify(userEnv),\n          chatProfile: chatProfile ? encodeURIComponent(chatProfile) : ''\n        }\n      });\n      setSession((old) => {\n        old?.socket?.removeAllListeners();\n        old?.socket?.close();\n        return {\n          socket\n        };\n      });\n\n      socket.on('connect', () => {\n        socket.emit('connection_successful');\n        setSession((s) => ({ ...s!, error: false }));\n        socket.emit('fetch_favorites');\n        setMcps((prev) =>\n          prev.map((mcp) => {\n            let promise;\n            if (mcp.clientType === 'sse') {\n              promise = client.connectSseMCP(sessionId, mcp.name, mcp.url!);\n            } else if (mcp.clientType === 'streamable-http') {\n              promise = client.connectStreamableHttpMCP(\n                sessionId,\n                mcp.name,\n                mcp.url!,\n                mcp.headers || {}\n              );\n            } else {\n              promise = client.connectStdioMCP(\n                sessionId,\n                mcp.name,\n                mcp.command!\n              );\n            }\n            promise\n              .then(async ({ success, mcp }) => {\n                setMcps((prev) =>\n                  prev.map((existingMcp) => {\n                    if (existingMcp.name === mcp.name) {\n                      return {\n                        ...existingMcp,\n                        status: success ? 'connected' : 'failed',\n                        tools: mcp ? mcp.tools : existingMcp.tools\n                      };\n                    }\n                    return existingMcp;\n                  })\n                );\n              })\n              .catch(() => {\n                setMcps((prev) =>\n                  prev.map((existingMcp) => {\n                    if (existingMcp.name === mcp.name) {\n                      return {\n                        ...existingMcp,\n                        status: 'failed'\n                      };\n                    }\n                    return existingMcp;\n                  })\n                );\n              });\n            return { ...mcp, status: 'connecting' };\n          })\n        );\n      });\n\n      socket.on('connect_error', (_) => {\n        setSession((s) => ({ ...s!, error: true }));\n      });\n\n      socket.on('task_start', () => {\n        setLoading(true);\n      });\n\n      socket.on('task_end', () => {\n        setLoading(false);\n      });\n\n      socket.on('reload', () => {\n        socket.emit('clear_session');\n        window.location.reload();\n      });\n\n      socket.on('audio_connection', async (state: 'on' | 'off') => {\n        if (state === 'on') {\n          let isFirstChunk = true;\n          const startTime = Date.now();\n          const mimeType = 'pcm16';\n          try {\n            await wavRecorder.begin();\n            await wavStreamPlayer.connect();\n            await wavRecorder.record(async (data) => {\n              const elapsedTime = Date.now() - startTime;\n              socket.emit('audio_chunk', {\n                isStart: isFirstChunk,\n                mimeType,\n                elapsedTime,\n                data: data.mono\n              });\n              isFirstChunk = false;\n            });\n            wavStreamPlayer.onStop = () => setIsAiSpeaking(false);\n          } catch {\n            try {\n              await wavRecorder.end();\n            } catch {\n              // ignored\n            }\n            await wavStreamPlayer.interrupt();\n            socket.emit('audio_end');\n            setAudioConnection('off');\n            return;\n          }\n        } else {\n          await wavRecorder.end();\n          await wavStreamPlayer.interrupt();\n        }\n        setAudioConnection(state);\n      });\n\n      socket.on('audio_chunk', (chunk: OutputAudioChunk) => {\n        wavStreamPlayer.add16BitPCM(chunk.data, chunk.track);\n        setIsAiSpeaking(true);\n      });\n\n      socket.on('audio_interrupt', () => {\n        wavStreamPlayer.interrupt();\n      });\n\n      socket.on('resume_thread', (thread: IThread) => {\n        const isReadOnlyView = Boolean(\n          (thread as any)?.metadata?.viewer_read_only\n        );\n        if (!isReadOnlyView && idToResume && thread.id !== idToResume) {\n          window.location.href = `/thread/${thread.id}`;\n        }\n        if (!isReadOnlyView && idToResume) {\n          setCurrentThreadId(thread.id);\n        }\n        let messages: IStep[] = [];\n        for (const step of thread.steps) {\n          messages = addMessage(messages, step);\n        }\n        if (thread.metadata?.chat_profile) {\n          setChatProfile(thread.metadata?.chat_profile);\n        }\n        if (thread.metadata?.chat_settings) {\n          setChatSettingsValue(thread.metadata?.chat_settings);\n        }\n        setMessages(messages);\n        const elements = thread.elements || [];\n        setTasklists(\n          (elements as ITasklistElement[]).filter((e) => e.type === 'tasklist')\n        );\n        setElements(\n          (elements as IMessageElement[]).filter(\n            (e) => ['avatar', 'tasklist'].indexOf(e.type) === -1\n          )\n        );\n      });\n\n      socket.on('resume_thread_error', (error?: string) => {\n        setThreadResumeError(error);\n      });\n\n      socket.on('new_message', (message: IStep) => {\n        setMessages((oldMessages) => addMessage(oldMessages, message));\n      });\n\n      socket.on(\n        'first_interaction',\n        (event: { interaction: string; thread_id: string }) => {\n          setFirstUserInteraction(event.interaction);\n          setCurrentThreadId(event.thread_id);\n        }\n      );\n\n      socket.on('update_message', (message: IStep) => {\n        setMessages((oldMessages) =>\n          updateMessageById(oldMessages, message.id, message)\n        );\n      });\n\n      socket.on('delete_message', (message: IStep) => {\n        setMessages((oldMessages) =>\n          deleteMessageById(oldMessages, message.id)\n        );\n      });\n\n      socket.on('stream_start', (message: IStep) => {\n        setMessages((oldMessages) => addMessage(oldMessages, message));\n      });\n\n      socket.on(\n        'stream_token',\n        ({ id, token, isSequence, isInput }: IToken) => {\n          setMessages((oldMessages) =>\n            updateMessageContentById(\n              oldMessages,\n              id,\n              token,\n              isSequence,\n              isInput\n            )\n          );\n        }\n      );\n\n      socket.on('ask', ({ msg, spec }, callback) => {\n        setAskUser({ spec, callback, parentId: msg.parentId });\n        setMessages((oldMessages) => addMessage(oldMessages, msg));\n\n        setLoading(false);\n      });\n\n      socket.on('ask_timeout', () => {\n        setAskUser(undefined);\n        setLoading(false);\n      });\n\n      socket.on('clear_ask', () => {\n        setAskUser(undefined);\n      });\n\n      socket.on('call_fn', ({ name, args }, callback) => {\n        setCallFn({ name, args, callback });\n      });\n\n      socket.on('clear_call_fn', () => {\n        setCallFn(undefined);\n      });\n\n      socket.on('call_fn_timeout', () => {\n        setCallFn(undefined);\n      });\n\n      socket.on('chat_settings', (inputs: any) => {\n        setChatSettingsInputs(inputs);\n        resetChatSettingsValue();\n      });\n\n      socket.on('set_commands', (commands: ICommand[]) => {\n        setCommands(commands);\n      });\n\n      socket.on('set_modes', (modes: IMode[]) => {\n        setModes(modes);\n      });\n\n      socket.on('set_favorites', (steps: IStep[]) => {\n        setFavoriteMessages(steps);\n      });\n\n      socket.on('set_sidebar_title', (title: string) => {\n        setSideView((prev) => {\n          if (prev?.title === title) return prev;\n          return { title, elements: prev?.elements || [] };\n        });\n      });\n\n      socket.on(\n        'set_sidebar_elements',\n        ({ elements, key }: { elements: IMessageElement[]; key?: string }) => {\n          if (!elements.length) {\n            setSideView(undefined);\n          } else {\n            elements.forEach((element) => {\n              if (!element.url && element.chainlitKey) {\n                element.url = client.getElementUrl(\n                  element.chainlitKey,\n                  sessionId\n                );\n              }\n            });\n            setSideView((prev) => {\n              if (prev?.key === key) return prev;\n              return { title: prev?.title || '', elements: elements, key };\n            });\n          }\n        }\n      );\n\n      socket.on('element', (element: IElement) => {\n        if (!element.url && element.chainlitKey) {\n          element.url = client.getElementUrl(element.chainlitKey, sessionId);\n        }\n\n        if (element.type === 'tasklist') {\n          setTasklists((old) => {\n            const index = old.findIndex((e) => e.id === element.id);\n            if (index === -1) {\n              return [...old, element];\n            } else {\n              return [...old.slice(0, index), element, ...old.slice(index + 1)];\n            }\n          });\n        } else {\n          setElements((old) => {\n            const index = old.findIndex((e) => e.id === element.id);\n            if (index === -1) {\n              return [...old, element];\n            } else {\n              return [...old.slice(0, index), element, ...old.slice(index + 1)];\n            }\n          });\n        }\n      });\n\n      socket.on('remove_element', (remove: { id: string }) => {\n        setElements((old) => {\n          return old.filter((e) => e.id !== remove.id);\n        });\n        setTasklists((old) => {\n          return old.filter((e) => e.id !== remove.id);\n        });\n      });\n\n      socket.on('action', (action: IAction) => {\n        setActions((old) => [...old, action]);\n      });\n\n      socket.on('remove_action', (action: IAction) => {\n        setActions((old) => {\n          const index = old.findIndex((a) => a.id === action.id);\n          if (index === -1) return old;\n          return [...old.slice(0, index), ...old.slice(index + 1)];\n        });\n      });\n\n      socket.on('token_usage', (count: number) => {\n        setTokenCount((old) => old + count);\n      });\n\n      socket.on('window_message', (data: any) => {\n        if (window.parent) {\n          window.parent.postMessage(data, '*');\n        }\n      });\n\n      socket.on('toast', (data: { message: string; type: string }) => {\n        if (!data.message) {\n          console.warn('No message received for toast.');\n          return;\n        }\n\n        switch (data.type) {\n          case 'info':\n            toast.info(data.message);\n            break;\n          case 'error':\n            toast.error(data.message);\n            break;\n          case 'success':\n            toast.success(data.message);\n            break;\n          case 'warning':\n            toast.warning(data.message);\n            break;\n          default:\n            toast(data.message);\n            break;\n        }\n      });\n    },\n    [setSession, sessionId, idToResume, chatProfile]\n  );\n\n  const connect = useCallback(debounce(_connect, 200), [_connect]);\n\n  const disconnect = useCallback(() => {\n    if (session?.socket) {\n      session.socket.removeAllListeners();\n      session.socket.close();\n    }\n  }, [session]);\n\n  return {\n    connect,\n    disconnect,\n    session,\n    sessionId,\n    chatProfile,\n    idToResume,\n    setChatProfile\n  };\n};\n\nexport { useChatSession };\n"
  },
  {
    "path": "libs/react-client/src/useConfig.ts",
    "content": "import { useEffect, useRef } from 'react';\nimport { useRecoilState, useRecoilValue } from 'recoil';\n\nimport { useApi, useAuth } from './api';\nimport { configState, chatProfileState } from './state';\nimport { IChainlitConfig } from './types';\n\nconst useConfig = () => {\n  const [config, setConfig] = useRecoilState(configState);\n  const { isAuthenticated } = useAuth();\n  const chatProfile = useRecoilValue(chatProfileState);\n  const language = navigator.language || 'en-US';\n  const prevChatProfileRef = useRef(chatProfile);\n\n  // Build the API URL with optional chat profile parameter\n  const apiUrl = isAuthenticated\n    ? `/project/settings?language=${language}${chatProfile ? `&chat_profile=${encodeURIComponent(chatProfile)}` : ''}`\n    : null;\n\n  // Always fetch if we don't have config and we're authenticated\n  const shouldFetch = isAuthenticated && !config;\n\n  const { data, error, isLoading } = useApi<IChainlitConfig>(\n    shouldFetch ? apiUrl : null\n  );\n\n  useEffect(() => {\n    if (!data) return;\n    setConfig(data);\n  }, [data, setConfig]);\n\n  // Clear config when chat profile changes to force re-fetch\n  useEffect(() => {\n    if (prevChatProfileRef.current !== chatProfile) {\n      setConfig(undefined);\n      prevChatProfileRef.current = chatProfile;\n    }\n  }, [chatProfile, setConfig]);\n\n  return { config, error, isLoading, language };\n};\n\nexport { useConfig };\n"
  },
  {
    "path": "libs/react-client/src/utils/group.ts",
    "content": "import { IThread } from 'src/types';\n\nexport const groupByDate = (data: IThread[]) => {\n  const groupedData: { [key: string]: IThread[] } = {};\n\n  const today = new Date();\n  today.setHours(0, 0, 0, 0);\n\n  [...data]\n    .sort(\n      (a, b) =>\n        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n    )\n    .forEach((item) => {\n      const threadDate = new Date(item.createdAt);\n      threadDate.setHours(0, 0, 0, 0);\n\n      const daysDiff = Math.floor(\n        (today.getTime() - threadDate.getTime()) / 86400000\n      );\n\n      let category: string;\n      if (daysDiff === 0) {\n        category = 'Today';\n      } else if (daysDiff === 1) {\n        category = 'Yesterday';\n      } else if (daysDiff <= 7) {\n        category = 'Previous 7 days';\n      } else if (daysDiff <= 30) {\n        category = 'Previous 30 days';\n      } else {\n        category = threadDate.toLocaleString('default', {\n          month: 'long',\n          year: 'numeric'\n        });\n      }\n\n      groupedData[category] ??= [];\n      groupedData[category].push(item);\n    });\n\n  return groupedData;\n};\n"
  },
  {
    "path": "libs/react-client/src/utils/message.ts",
    "content": "import { isEqual } from 'lodash';\n\nimport { IStep } from '..';\n\nconst nestMessages = (messages: IStep[]): IStep[] => {\n  let nestedMessages: IStep[] = [];\n\n  for (const message of messages) {\n    nestedMessages = addMessage(nestedMessages, message);\n  }\n\n  return nestedMessages;\n};\n\nconst isLastMessage = (messages: IStep[], index: number) => {\n  if (messages.length - 1 === index) {\n    return true;\n  }\n\n  for (let i = index + 1; i < messages.length; i++) {\n    if (messages[i].streaming) {\n      continue;\n    } else {\n      return false;\n    }\n  }\n\n  return true;\n};\n\n// Nested messages utils\n\nconst addMessage = (messages: IStep[], message: IStep): IStep[] => {\n  if (hasMessageById(messages, message.id)) {\n    return updateMessageById(messages, message.id, message);\n  } else if ('parentId' in message && message.parentId) {\n    return addMessageToParent(messages, message.parentId, message);\n  } else if ('indent' in message && message.indent && message.indent > 0) {\n    return addIndentMessage(messages, message.indent, message);\n  } else {\n    return [...messages, message];\n  }\n};\n\nconst addIndentMessage = (\n  messages: IStep[],\n  indent: number,\n  newMessage: IStep,\n  currentIndentation: number = 0\n): IStep[] => {\n  if (messages.length === 0) {\n    return [newMessage];\n  }\n\n  const index = messages.length - 1;\n  const msg = messages[index];\n  const msgSteps = msg.steps || [];\n\n  if (currentIndentation + 1 === indent) {\n    const updatedMsg = {\n      ...msg,\n      steps: [...msgSteps, newMessage]\n    };\n    const nextMessages = [...messages];\n    nextMessages[index] = updatedMsg;\n    return nextMessages;\n  } else {\n    const updatedSteps = addIndentMessage(\n      msgSteps,\n      indent,\n      newMessage,\n      currentIndentation + 1\n    );\n\n    if (updatedSteps === msgSteps) {\n      return messages;\n    }\n\n    const nextMessages = [...messages];\n    nextMessages[index] = { ...msg, steps: updatedSteps };\n    return nextMessages;\n  }\n};\n\nconst addMessageToParent = (\n  messages: IStep[],\n  parentId: string,\n  newMessage: IStep\n): IStep[] => {\n  let hasChanges = false;\n\n  const nextMessages = messages.map((msg) => {\n    if (isEqual(msg.id, parentId)) {\n      hasChanges = true;\n      return {\n        ...msg,\n        steps: msg.steps ? [...msg.steps, newMessage] : [newMessage]\n      };\n    } else if (hasMessageById(messages, parentId) && msg.steps) {\n      const updatedSteps = addMessageToParent(msg.steps, parentId, newMessage);\n      if (updatedSteps !== msg.steps) {\n        hasChanges = true;\n        return { ...msg, steps: updatedSteps };\n      }\n    }\n    return msg;\n  });\n\n  return hasChanges ? nextMessages : messages;\n};\n\nconst findMessageById = (\n  messages: IStep[],\n  messageId: string\n): IStep | undefined => {\n  for (const message of messages) {\n    if (isEqual(message.id, messageId)) {\n      return message;\n    } else if (message.steps && message.steps.length > 0) {\n      const foundMessage = findMessageById(message.steps, messageId);\n      if (foundMessage) {\n        return foundMessage;\n      }\n    }\n  }\n  return undefined;\n};\n\nconst hasMessageById = (messages: IStep[], messageId: string): boolean => {\n  return findMessageById(messages, messageId) !== undefined;\n};\n\nconst updateMessageById = (\n  messages: IStep[],\n  messageId: string,\n  updatedMessage: IStep\n): IStep[] => {\n  let hasChanges = false;\n  const nextMessages = messages.map((msg) => {\n    if (isEqual(msg.id, messageId)) {\n      hasChanges = true;\n      return { ...msg, ...updatedMessage };\n    } else if (msg.steps) {\n      const updatedSteps = updateMessageById(\n        msg.steps,\n        messageId,\n        updatedMessage\n      );\n      if (updatedSteps !== msg.steps) {\n        hasChanges = true;\n        return { ...msg, steps: updatedSteps };\n      }\n    }\n    return msg;\n  });\n\n  return hasChanges ? nextMessages : messages;\n};\n\nconst deleteMessageById = (messages: IStep[], messageId: string): IStep[] => {\n  let hasChanges = false;\n  const nextMessages = messages.reduce((acc, msg) => {\n    if (msg.id === messageId) {\n      hasChanges = true;\n      return acc;\n    } else if (msg.steps) {\n      const updatedSteps = deleteMessageById(msg.steps, messageId);\n      if (updatedSteps !== msg.steps) {\n        hasChanges = true;\n        acc.push({ ...msg, steps: updatedSteps });\n        return acc;\n      }\n    }\n    acc.push(msg);\n    return acc;\n  }, [] as IStep[]);\n\n  return hasChanges ? nextMessages : messages;\n};\n\nconst updateMessageContentById = (\n  messages: IStep[],\n  messageId: number | string,\n  updatedContent: string,\n  isSequence: boolean,\n  isInput: boolean\n): IStep[] => {\n  let hasChanges = false;\n  const nextMessages = messages.map((msg) => {\n    if (isEqual(msg.id, messageId)) {\n      hasChanges = true;\n      const newMsg = { ...msg };\n      if ('content' in newMsg && newMsg.content !== undefined) {\n        if (isSequence) {\n          newMsg.content = updatedContent;\n        } else {\n          newMsg.content += updatedContent;\n        }\n      } else if (isInput) {\n        if ('input' in newMsg && newMsg.input !== undefined) {\n          if (isSequence) {\n            newMsg.input = updatedContent;\n          } else {\n            newMsg.input += updatedContent;\n          }\n        }\n      } else {\n        if ('output' in newMsg && newMsg.output !== undefined) {\n          if (isSequence) {\n            newMsg.output = updatedContent;\n          } else {\n            newMsg.output += updatedContent;\n          }\n        }\n      }\n      return newMsg;\n    } else if (msg.steps) {\n      const updatedSteps = updateMessageContentById(\n        msg.steps,\n        messageId,\n        updatedContent,\n        isSequence,\n        isInput\n      );\n      if (updatedSteps !== msg.steps) {\n        hasChanges = true;\n        return { ...msg, steps: updatedSteps };\n      }\n    }\n    return msg;\n  });\n\n  return hasChanges ? nextMessages : messages;\n};\n\nexport {\n  addMessageToParent,\n  addMessage,\n  deleteMessageById,\n  hasMessageById,\n  isLastMessage,\n  nestMessages,\n  updateMessageById,\n  updateMessageContentById\n};\n"
  },
  {
    "path": "libs/react-client/src/wavtools/analysis/audio_analysis.js",
    "content": "import {\n  noteFrequencies,\n  noteFrequencyLabels,\n  voiceFrequencies,\n  voiceFrequencyLabels\n} from './constants.js';\n\n/**\n * Output of AudioAnalysis for the frequency domain of the audio\n * @typedef {Object} AudioAnalysisOutputType\n * @property {Float32Array} values Amplitude of this frequency between {0, 1} inclusive\n * @property {number[]} frequencies Raw frequency bucket values\n * @property {string[]} labels Labels for the frequency bucket values\n */\n\n/**\n * Analyzes audio for visual output\n * @class\n */\nexport class AudioAnalysis {\n  /**\n   * Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range\n   * returns human-readable formatting and labels\n   * @param {AnalyserNode} analyser\n   * @param {number} sampleRate\n   * @param {Float32Array} [fftResult]\n   * @param {\"frequency\"|\"music\"|\"voice\"} [analysisType]\n   * @param {number} [minDecibels] default -100\n   * @param {number} [maxDecibels] default -30\n   * @returns {AudioAnalysisOutputType}\n   */\n  static getFrequencies(\n    analyser,\n    sampleRate,\n    fftResult,\n    analysisType = 'frequency',\n    minDecibels = -100,\n    maxDecibels = -30\n  ) {\n    if (!fftResult) {\n      fftResult = new Float32Array(analyser.frequencyBinCount);\n      analyser.getFloatFrequencyData(fftResult);\n    }\n    const nyquistFrequency = sampleRate / 2;\n    const frequencyStep = (1 / fftResult.length) * nyquistFrequency;\n    let outputValues;\n    let frequencies;\n    let labels;\n    if (analysisType === 'music' || analysisType === 'voice') {\n      const useFrequencies =\n        analysisType === 'voice' ? voiceFrequencies : noteFrequencies;\n      const aggregateOutput = Array(useFrequencies.length).fill(minDecibels);\n      for (let i = 0; i < fftResult.length; i++) {\n        const frequency = i * frequencyStep;\n        const amplitude = fftResult[i];\n        for (let n = useFrequencies.length - 1; n >= 0; n--) {\n          if (frequency > useFrequencies[n]) {\n            aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude);\n            break;\n          }\n        }\n      }\n      outputValues = aggregateOutput;\n      frequencies =\n        analysisType === 'voice' ? voiceFrequencies : noteFrequencies;\n      labels =\n        analysisType === 'voice' ? voiceFrequencyLabels : noteFrequencyLabels;\n    } else {\n      outputValues = Array.from(fftResult);\n      frequencies = outputValues.map((_, i) => frequencyStep * i);\n      labels = frequencies.map((f) => `${f.toFixed(2)} Hz`);\n    }\n    // We normalize to {0, 1}\n    const normalizedOutput = outputValues.map((v) => {\n      return Math.max(\n        0,\n        Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1)\n      );\n    });\n    const values = new Float32Array(normalizedOutput);\n    return {\n      values,\n      frequencies,\n      labels\n    };\n  }\n\n  /**\n   * Creates a new AudioAnalysis instance for an HTMLAudioElement\n   * @param {HTMLAudioElement} audioElement\n   * @param {AudioBuffer|null} [audioBuffer] If provided, will cache all frequency domain data from the buffer\n   * @returns {AudioAnalysis}\n   */\n  constructor(audioElement, audioBuffer = null) {\n    this.fftResults = [];\n    if (audioBuffer) {\n      /**\n       * Modified from\n       * https://stackoverflow.com/questions/75063715/using-the-web-audio-api-to-analyze-a-song-without-playing\n       *\n       * We do this to populate FFT values for the audio if provided an `audioBuffer`\n       * The reason to do this is that Safari fails when using `createMediaElementSource`\n       * This has a non-zero RAM cost so we only opt-in to run it on Safari, Chrome is better\n       */\n      const { length, sampleRate } = audioBuffer;\n      const offlineAudioContext = new OfflineAudioContext({\n        length,\n        sampleRate\n      });\n      const source = offlineAudioContext.createBufferSource();\n      source.buffer = audioBuffer;\n      const analyser = offlineAudioContext.createAnalyser();\n      analyser.fftSize = 8192;\n      analyser.smoothingTimeConstant = 0.1;\n      source.connect(analyser);\n      // limit is :: 128 / sampleRate;\n      // but we just want 60fps - cuts ~1s from 6MB to 1MB of RAM\n      const renderQuantumInSeconds = 1 / 60;\n      const durationInSeconds = length / sampleRate;\n      const analyze = (index) => {\n        const suspendTime = renderQuantumInSeconds * index;\n        if (suspendTime < durationInSeconds) {\n          offlineAudioContext.suspend(suspendTime).then(() => {\n            const fftResult = new Float32Array(analyser.frequencyBinCount);\n            analyser.getFloatFrequencyData(fftResult);\n            this.fftResults.push(fftResult);\n            analyze(index + 1);\n          });\n        }\n        if (index === 1) {\n          offlineAudioContext.startRendering();\n        } else {\n          offlineAudioContext.resume();\n        }\n      };\n      source.start(0);\n      analyze(1);\n      this.audio = audioElement;\n      this.context = offlineAudioContext;\n      this.analyser = analyser;\n      this.sampleRate = sampleRate;\n      this.audioBuffer = audioBuffer;\n    } else {\n      const audioContext = new AudioContext();\n      const track = audioContext.createMediaElementSource(audioElement);\n      const analyser = audioContext.createAnalyser();\n      analyser.fftSize = 8192;\n      analyser.smoothingTimeConstant = 0.1;\n      track.connect(analyser);\n      analyser.connect(audioContext.destination);\n      this.audio = audioElement;\n      this.context = audioContext;\n      this.analyser = analyser;\n      this.sampleRate = this.context.sampleRate;\n      this.audioBuffer = null;\n    }\n  }\n\n  /**\n   * Gets the current frequency domain data from the playing audio track\n   * @param {\"frequency\"|\"music\"|\"voice\"} [analysisType]\n   * @param {number} [minDecibels] default -100\n   * @param {number} [maxDecibels] default -30\n   * @returns {AudioAnalysisOutputType}\n   */\n  getFrequencies(\n    analysisType = 'frequency',\n    minDecibels = -100,\n    maxDecibels = -30\n  ) {\n    let fftResult = null;\n    if (this.audioBuffer && this.fftResults.length) {\n      const pct = this.audio.currentTime / this.audio.duration;\n      const index = Math.min(\n        (pct * this.fftResults.length) | 0,\n        this.fftResults.length - 1\n      );\n      fftResult = this.fftResults[index];\n    }\n    return AudioAnalysis.getFrequencies(\n      this.analyser,\n      this.sampleRate,\n      fftResult,\n      analysisType,\n      minDecibels,\n      maxDecibels\n    );\n  }\n\n  /**\n   * Resume the internal AudioContext if it was suspended due to the lack of\n   * user interaction when the AudioAnalysis was instantiated.\n   * @returns {Promise<true>}\n   */\n  async resumeIfSuspended() {\n    if (this.context.state === 'suspended') {\n      await this.context.resume();\n    }\n    return true;\n  }\n}\n\nglobalThis.AudioAnalysis = AudioAnalysis;\n"
  },
  {
    "path": "libs/react-client/src/wavtools/analysis/constants.js",
    "content": "/**\n * Constants for help with visualization\n * Helps map frequency ranges from Fast Fourier Transform\n * to human-interpretable ranges, notably music ranges and\n * human vocal ranges.\n */\n\n// Eighth octave frequencies\nconst octave8Frequencies = [\n  4186.01, 4434.92, 4698.63, 4978.03, 5274.04, 5587.65, 5919.91, 6271.93,\n  6644.88, 7040.0, 7458.62, 7902.13\n];\n\n// Labels for each of the above frequencies\nconst octave8FrequencyLabels = [\n  'C',\n  'C#',\n  'D',\n  'D#',\n  'E',\n  'F',\n  'F#',\n  'G',\n  'G#',\n  'A',\n  'A#',\n  'B'\n];\n\n/**\n * All note frequencies from 1st to 8th octave\n * in format \"A#8\" (A#, 8th octave)\n */\nexport const noteFrequencies = [];\nexport const noteFrequencyLabels = [];\nfor (let i = 1; i <= 8; i++) {\n  for (let f = 0; f < octave8Frequencies.length; f++) {\n    const freq = octave8Frequencies[f];\n    noteFrequencies.push(freq / Math.pow(2, 8 - i));\n    noteFrequencyLabels.push(octave8FrequencyLabels[f] + i);\n  }\n}\n\n/**\n * Subset of the note frequencies between 32 and 2000 Hz\n * 6 octave range: C1 to B6\n */\nconst voiceFrequencyRange = [32.0, 2000.0];\nexport const voiceFrequencies = noteFrequencies.filter((_, i) => {\n  return (\n    noteFrequencies[i] > voiceFrequencyRange[0] &&\n    noteFrequencies[i] < voiceFrequencyRange[1]\n  );\n});\nexport const voiceFrequencyLabels = noteFrequencyLabels.filter((_, i) => {\n  return (\n    noteFrequencies[i] > voiceFrequencyRange[0] &&\n    noteFrequencies[i] < voiceFrequencyRange[1]\n  );\n});\n"
  },
  {
    "path": "libs/react-client/src/wavtools/index.ts",
    "content": "// Courtesy of https://github.com/openai/openai-realtime-console\nimport { AudioAnalysis } from './analysis/audio_analysis.js';\nimport { WavPacker } from './wav_packer.js';\nimport { WavRecorder } from './wav_recorder.js';\nimport { WavStreamPlayer } from './wav_stream_player.js';\n\nexport { AudioAnalysis, WavPacker, WavStreamPlayer, WavRecorder };\n"
  },
  {
    "path": "libs/react-client/src/wavtools/wav_packer.js",
    "content": "/**\n * Raw wav audio file contents\n * @typedef {Object} WavPackerAudioType\n * @property {Blob} blob\n * @property {string} url\n * @property {number} channelCount\n * @property {number} sampleRate\n * @property {number} duration\n */\n\n/**\n * Utility class for assembling PCM16 \"audio/wav\" data\n * @class\n */\nexport class WavPacker {\n  /**\n   * Converts Float32Array of amplitude data to ArrayBuffer in Int16Array format\n   * @param {Float32Array} float32Array\n   * @returns {ArrayBuffer}\n   */\n  static floatTo16BitPCM(float32Array) {\n    const buffer = new ArrayBuffer(float32Array.length * 2);\n    const view = new DataView(buffer);\n    let offset = 0;\n    for (let i = 0; i < float32Array.length; i++, offset += 2) {\n      let s = Math.max(-1, Math.min(1, float32Array[i]));\n      view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);\n    }\n    return buffer;\n  }\n\n  /**\n   * Concatenates two ArrayBuffers\n   * @param {ArrayBuffer} leftBuffer\n   * @param {ArrayBuffer} rightBuffer\n   * @returns {ArrayBuffer}\n   */\n  static mergeBuffers(leftBuffer, rightBuffer) {\n    const tmpArray = new Uint8Array(\n      leftBuffer.byteLength + rightBuffer.byteLength\n    );\n    tmpArray.set(new Uint8Array(leftBuffer), 0);\n    tmpArray.set(new Uint8Array(rightBuffer), leftBuffer.byteLength);\n    return tmpArray.buffer;\n  }\n\n  /**\n   * Packs data into an Int16 format\n   * @private\n   * @param {number} size 0 = 1x Int16, 1 = 2x Int16\n   * @param {number} arg value to pack\n   * @returns\n   */\n  _packData(size, arg) {\n    return [\n      new Uint8Array([arg, arg >> 8]),\n      new Uint8Array([arg, arg >> 8, arg >> 16, arg >> 24])\n    ][size];\n  }\n\n  /**\n   * Packs audio into \"audio/wav\" Blob\n   * @param {number} sampleRate\n   * @param {{bitsPerSample: number, channels: Array<Float32Array>, data: Int16Array}} audio\n   * @returns {WavPackerAudioType}\n   */\n  pack(sampleRate, audio) {\n    if (!audio?.bitsPerSample) {\n      throw new Error(`Missing \"bitsPerSample\"`);\n    } else if (!audio?.channels) {\n      throw new Error(`Missing \"channels\"`);\n    } else if (!audio?.data) {\n      throw new Error(`Missing \"data\"`);\n    }\n    const { bitsPerSample, channels, data } = audio;\n    const output = [\n      // Header\n      'RIFF',\n      this._packData(\n        1,\n        4 + (8 + 24) /* chunk 1 length */ + (8 + 8) /* chunk 2 length */\n      ), // Length\n      'WAVE',\n      // chunk 1\n      'fmt ', // Sub-chunk identifier\n      this._packData(1, 16), // Chunk length\n      this._packData(0, 1), // Audio format (1 is linear quantization)\n      this._packData(0, channels.length),\n      this._packData(1, sampleRate),\n      this._packData(1, (sampleRate * channels.length * bitsPerSample) / 8), // Byte rate\n      this._packData(0, (channels.length * bitsPerSample) / 8),\n      this._packData(0, bitsPerSample),\n      // chunk 2\n      'data', // Sub-chunk identifier\n      this._packData(\n        1,\n        (channels[0].length * channels.length * bitsPerSample) / 8\n      ), // Chunk length\n      data\n    ];\n    const blob = new Blob(output, { type: 'audio/mpeg' });\n    const url = URL.createObjectURL(blob);\n    return {\n      blob,\n      url,\n      channelCount: channels.length,\n      sampleRate,\n      duration: data.byteLength / (channels.length * sampleRate * 2)\n    };\n  }\n}\n\nglobalThis.WavPacker = WavPacker;\n"
  },
  {
    "path": "libs/react-client/src/wavtools/wav_recorder.js",
    "content": "import { AudioAnalysis } from './analysis/audio_analysis.js';\nimport { WavPacker } from './wav_packer.js';\nimport { AudioProcessorSrc } from './worklets/audio_processor.js';\n\n/**\n * Decodes audio into a wav file\n * @typedef {Object} DecodedAudioType\n * @property {Blob} blob\n * @property {string} url\n * @property {Float32Array} values\n * @property {AudioBuffer} audioBuffer\n */\n\n/**\n * Records live stream of user audio as PCM16 \"audio/wav\" data\n * @class\n */\nexport class WavRecorder {\n  /**\n   * Create a new WavRecorder instance\n   * @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options]\n   * @returns {WavRecorder}\n   */\n  constructor({\n    sampleRate = 24000,\n    outputToSpeakers = false,\n    debug = false\n  } = {}) {\n    // Script source\n    this.scriptSrc = AudioProcessorSrc;\n    // Config\n    this.sampleRate = sampleRate;\n    this.outputToSpeakers = outputToSpeakers;\n    this.debug = !!debug;\n    this._deviceChangeCallback = null;\n    this._devices = [];\n    // State variables\n    this.stream = null;\n    this.processor = null;\n    this.source = null;\n    this.node = null;\n    this.recording = false;\n    // Event handling with AudioWorklet\n    this._lastEventId = 0;\n    this.eventReceipts = {};\n    this.eventTimeout = 5000;\n    // Process chunks of audio\n    this._chunkProcessor = () => {};\n    this._chunkProcessorSize = void 0;\n    this._chunkProcessorBuffer = {\n      raw: new ArrayBuffer(0),\n      mono: new ArrayBuffer(0)\n    };\n  }\n\n  /**\n   * Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer\n   * @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData\n   * @param {number} sampleRate\n   * @param {number} fromSampleRate\n   * @returns {Promise<DecodedAudioType>}\n   */\n  static async decode(audioData, sampleRate = 24000, fromSampleRate = -1) {\n    const context = new AudioContext({ sampleRate });\n    let arrayBuffer;\n    let blob;\n    if (audioData instanceof Blob) {\n      if (fromSampleRate !== -1) {\n        throw new Error(\n          `Can not specify \"fromSampleRate\" when reading from Blob`\n        );\n      }\n      blob = audioData;\n      arrayBuffer = await blob.arrayBuffer();\n    } else if (audioData instanceof ArrayBuffer) {\n      if (fromSampleRate !== -1) {\n        throw new Error(\n          `Can not specify \"fromSampleRate\" when reading from ArrayBuffer`\n        );\n      }\n      arrayBuffer = audioData;\n      blob = new Blob([arrayBuffer], { type: 'audio/wav' });\n    } else {\n      let float32Array;\n      let data;\n      if (audioData instanceof Int16Array) {\n        data = audioData;\n        float32Array = new Float32Array(audioData.length);\n        for (let i = 0; i < audioData.length; i++) {\n          float32Array[i] = audioData[i] / 0x8000;\n        }\n      } else if (audioData instanceof Float32Array) {\n        float32Array = audioData;\n      } else if (audioData instanceof Array) {\n        float32Array = new Float32Array(audioData);\n      } else {\n        throw new Error(\n          `\"audioData\" must be one of: Blob, Float32Arrray, Int16Array, ArrayBuffer, Array<number>`\n        );\n      }\n      if (fromSampleRate === -1) {\n        throw new Error(\n          `Must specify \"fromSampleRate\" when reading from Float32Array, In16Array or Array`\n        );\n      } else if (fromSampleRate < 3000) {\n        throw new Error(`Minimum \"fromSampleRate\" is 3000 (3kHz)`);\n      }\n      if (!data) {\n        data = WavPacker.floatTo16BitPCM(float32Array);\n      }\n      const audio = {\n        bitsPerSample: 16,\n        channels: [float32Array],\n        data\n      };\n      const packer = new WavPacker();\n      const result = packer.pack(fromSampleRate, audio);\n      blob = result.blob;\n      arrayBuffer = await blob.arrayBuffer();\n    }\n    const audioBuffer = await context.decodeAudioData(arrayBuffer);\n    const values = audioBuffer.getChannelData(0);\n    const url = URL.createObjectURL(blob);\n    return {\n      blob,\n      url,\n      values,\n      audioBuffer\n    };\n  }\n\n  /**\n   * Logs data in debug mode\n   * @param {...any} arguments\n   * @returns {true}\n   */\n  log() {\n    if (this.debug) {\n      this.log(...arguments);\n    }\n    return true;\n  }\n\n  /**\n   * Retrieves the current sampleRate for the recorder\n   * @returns {number}\n   */\n  getSampleRate() {\n    return this.sampleRate;\n  }\n\n  /**\n   * Retrieves the current status of the recording\n   * @returns {\"ended\"|\"paused\"|\"recording\"}\n   */\n  getStatus() {\n    if (!this.processor) {\n      return 'ended';\n    } else if (!this.recording) {\n      return 'paused';\n    } else {\n      return 'recording';\n    }\n  }\n\n  /**\n   * Sends an event to the AudioWorklet\n   * @private\n   * @param {string} name\n   * @param {{[key: string]: any}} data\n   * @param {AudioWorkletNode} [_processor]\n   * @returns {Promise<{[key: string]: any}>}\n   */\n  async _event(name, data = {}, _processor = null) {\n    _processor = _processor || this.processor;\n    if (!_processor) {\n      throw new Error('Can not send events without recording first');\n    }\n    const message = {\n      event: name,\n      id: this._lastEventId++,\n      data\n    };\n    _processor.port.postMessage(message);\n    const t0 = new Date().valueOf();\n    while (!this.eventReceipts[message.id]) {\n      if (new Date().valueOf() - t0 > this.eventTimeout) {\n        throw new Error(`Timeout waiting for \"${name}\" event`);\n      }\n      await new Promise((res) => setTimeout(() => res(true), 1));\n    }\n    const payload = this.eventReceipts[message.id];\n    delete this.eventReceipts[message.id];\n    return payload;\n  }\n\n  /**\n   * Sets device change callback, remove if callback provided is `null`\n   * @param {(Array<MediaDeviceInfo & {default: boolean}>): void|null} callback\n   * @returns {true}\n   */\n  listenForDeviceChange(callback) {\n    if (callback === null && this._deviceChangeCallback) {\n      navigator.mediaDevices.removeEventListener(\n        'devicechange',\n        this._deviceChangeCallback\n      );\n      this._deviceChangeCallback = null;\n    } else if (callback !== null) {\n      // Basically a debounce; we only want this called once when devices change\n      // And we only want the most recent callback() to be executed\n      // if a few are operating at the same time\n      let lastId = 0;\n      let lastDevices = [];\n      const serializeDevices = (devices) =>\n        devices\n          .map((d) => d.deviceId)\n          .sort()\n          .join(',');\n      const cb = async () => {\n        let id = ++lastId;\n        const devices = await this.listDevices();\n        if (id === lastId) {\n          if (serializeDevices(lastDevices) !== serializeDevices(devices)) {\n            lastDevices = devices;\n            callback(devices.slice());\n          }\n        }\n      };\n      navigator.mediaDevices.addEventListener('devicechange', cb);\n      cb();\n      this._deviceChangeCallback = cb;\n    }\n    return true;\n  }\n\n  /**\n   * Manually request permission to use the microphone\n   * @returns {Promise<true>}\n   */\n  async requestPermission() {\n    const permissionStatus = await navigator.permissions.query({\n      name: 'microphone'\n    });\n    if (permissionStatus.state === 'denied') {\n      window.alert('You must grant microphone access to use this feature.');\n    } else if (permissionStatus.state === 'prompt') {\n      try {\n        const stream = await navigator.mediaDevices.getUserMedia({\n          audio: true\n        });\n        const tracks = stream.getTracks();\n        tracks.forEach((track) => track.stop());\n      } catch (e) {\n        window.alert('You must grant microphone access to use this feature.');\n      }\n    }\n    return true;\n  }\n\n  /**\n   * List all eligible devices for recording, will request permission to use microphone\n   * @returns {Promise<Array<MediaDeviceInfo & {default: boolean}>>}\n   */\n  async listDevices() {\n    if (\n      !navigator.mediaDevices ||\n      !('enumerateDevices' in navigator.mediaDevices)\n    ) {\n      throw new Error('Could not request user devices');\n    }\n    await this.requestPermission();\n    const devices = await navigator.mediaDevices.enumerateDevices();\n    const audioDevices = devices.filter(\n      (device) => device.kind === 'audioinput'\n    );\n    const defaultDeviceIndex = audioDevices.findIndex(\n      (device) => device.deviceId === 'default'\n    );\n    const deviceList = [];\n    if (defaultDeviceIndex !== -1) {\n      let defaultDevice = audioDevices.splice(defaultDeviceIndex, 1)[0];\n      let existingIndex = audioDevices.findIndex(\n        (device) => device.groupId === defaultDevice.groupId\n      );\n      if (existingIndex !== -1) {\n        defaultDevice = audioDevices.splice(existingIndex, 1)[0];\n      }\n      defaultDevice.default = true;\n      deviceList.push(defaultDevice);\n    }\n    return deviceList.concat(audioDevices);\n  }\n\n  /**\n   * Begins a recording session and requests microphone permissions if not already granted\n   * Microphone recording indicator will appear on browser tab but status will be \"paused\"\n   * @param {string} [deviceId] if no device provided, default device will be used\n   * @returns {Promise<true>}\n   */\n  async begin(deviceId) {\n    if (this.processor) {\n      throw new Error(\n        `Already connected: please call .end() to start a new session`\n      );\n    }\n\n    if (\n      !navigator.mediaDevices ||\n      !('getUserMedia' in navigator.mediaDevices)\n    ) {\n      throw new Error('Could not request user media');\n    }\n    try {\n      const config = { audio: true };\n      if (deviceId) {\n        config.audio = { deviceId: { exact: deviceId } };\n      }\n      this.stream = await navigator.mediaDevices.getUserMedia(config);\n    } catch (err) {\n      throw new Error('Could not start media stream');\n    }\n\n    const context = new AudioContext({ sampleRate: this.sampleRate });\n    const source = context.createMediaStreamSource(this.stream);\n    // Load and execute the module script.\n    try {\n      await context.audioWorklet.addModule(this.scriptSrc);\n    } catch (e) {\n      console.error(e);\n      throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`);\n    }\n    const processor = new AudioWorkletNode(context, 'audio_processor');\n    processor.port.onmessage = (e) => {\n      const { event, id, data } = e.data;\n      if (event === 'receipt') {\n        this.eventReceipts[id] = data;\n      } else if (event === 'chunk') {\n        if (this._chunkProcessorSize) {\n          const buffer = this._chunkProcessorBuffer;\n          this._chunkProcessorBuffer = {\n            raw: WavPacker.mergeBuffers(buffer.raw, data.raw),\n            mono: WavPacker.mergeBuffers(buffer.mono, data.mono)\n          };\n          if (\n            this._chunkProcessorBuffer.mono.byteLength >=\n            this._chunkProcessorSize\n          ) {\n            this._chunkProcessor(this._chunkProcessorBuffer);\n            this._chunkProcessorBuffer = {\n              raw: new ArrayBuffer(0),\n              mono: new ArrayBuffer(0)\n            };\n          }\n        } else {\n          this._chunkProcessor(data);\n        }\n      }\n    };\n\n    const node = source.connect(processor);\n    const analyser = context.createAnalyser();\n    analyser.fftSize = 8192;\n    analyser.smoothingTimeConstant = 0.1;\n    node.connect(analyser);\n    if (this.outputToSpeakers) {\n      // eslint-disable-next-line no-console\n      console.warn(\n        'Warning: Output to speakers may affect sound quality,\\n' +\n          'especially due to system audio feedback preventative measures.\\n' +\n          'use only for debugging'\n      );\n      analyser.connect(context.destination);\n    }\n\n    this.source = source;\n    this.node = node;\n    this.analyser = analyser;\n    this.processor = processor;\n    return true;\n  }\n\n  /**\n   * Gets the current frequency domain data from the recording track\n   * @param {\"frequency\"|\"music\"|\"voice\"} [analysisType]\n   * @param {number} [minDecibels] default -100\n   * @param {number} [maxDecibels] default -30\n   * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType}\n   */\n  getFrequencies(\n    analysisType = 'frequency',\n    minDecibels = -100,\n    maxDecibels = -30\n  ) {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    }\n    return AudioAnalysis.getFrequencies(\n      this.analyser,\n      this.sampleRate,\n      null,\n      analysisType,\n      minDecibels,\n      maxDecibels\n    );\n  }\n\n  /**\n   * Pauses the recording\n   * Keeps microphone stream open but halts storage of audio\n   * @returns {Promise<true>}\n   */\n  async pause() {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    } else if (!this.recording) {\n      throw new Error('Already paused: please call .record() first');\n    }\n    if (this._chunkProcessorBuffer.raw.byteLength) {\n      this._chunkProcessor(this._chunkProcessorBuffer);\n    }\n    this.log('Pausing ...');\n    await this._event('stop');\n    this.recording = false;\n    return true;\n  }\n\n  /**\n   * Start recording stream and storing to memory from the connected audio source\n   * @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor]\n   * @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio\n   * @returns {Promise<true>}\n   */\n  async record(chunkProcessor = () => {}, chunkSize = 8192) {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    } else if (this.recording) {\n      throw new Error('Already recording: please call .pause() first');\n    } else if (typeof chunkProcessor !== 'function') {\n      throw new Error(`chunkProcessor must be a function`);\n    }\n    this._chunkProcessor = chunkProcessor;\n    this._chunkProcessorSize = chunkSize;\n    this._chunkProcessorBuffer = {\n      raw: new ArrayBuffer(0),\n      mono: new ArrayBuffer(0)\n    };\n    this.log('Recording ...');\n    await this._event('start');\n    this.recording = true;\n    return true;\n  }\n\n  /**\n   * Clears the audio buffer, empties stored recording\n   * @returns {Promise<true>}\n   */\n  async clear() {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    }\n    await this._event('clear');\n    return true;\n  }\n\n  /**\n   * Reads the current audio stream data\n   * @returns {Promise<{meanValues: Float32Array, channels: Array<Float32Array>}>}\n   */\n  async read() {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    }\n    this.log('Reading ...');\n    const result = await this._event('read');\n    return result;\n  }\n\n  /**\n   * Saves the current audio stream to a file\n   * @param {boolean} [force] Force saving while still recording\n   * @returns {Promise<import('./wav_packer.js').WavPackerAudioType>}\n   */\n  async save(force = false) {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    }\n    if (!force && this.recording) {\n      throw new Error(\n        'Currently recording: please call .pause() first, or call .save(true) to force'\n      );\n    }\n    this.log('Exporting ...');\n    const exportData = await this._event('export');\n    const packer = new WavPacker();\n    const result = packer.pack(this.sampleRate, exportData.audio);\n    return result;\n  }\n\n  /**\n   * Ends the current recording session and saves the result\n   * @returns {Promise<import('./wav_packer.js').WavPackerAudioType>}\n   */\n  async end() {\n    if (!this.processor) {\n      throw new Error('Session ended: please call .begin() first');\n    }\n\n    const _processor = this.processor;\n\n    this.log('Stopping ...');\n    await this._event('stop');\n    this.recording = false;\n    const tracks = this.stream.getTracks();\n    tracks.forEach((track) => track.stop());\n\n    this.log('Exporting ...');\n    const exportData = await this._event('export', {}, _processor);\n\n    this.processor.disconnect();\n    this.source.disconnect();\n    this.node.disconnect();\n    this.analyser.disconnect();\n    this.stream = null;\n    this.processor = null;\n    this.source = null;\n    this.node = null;\n\n    const packer = new WavPacker();\n    const result = packer.pack(this.sampleRate, exportData.audio);\n    return result;\n  }\n\n  /**\n   * Performs a full cleanup of WavRecorder instance\n   * Stops actively listening via microphone and removes existing listeners\n   * @returns {Promise<true>}\n   */\n  async quit() {\n    this.listenForDeviceChange(null);\n    if (this.processor) {\n      await this.end();\n    }\n    return true;\n  }\n}\n\nglobalThis.WavRecorder = WavRecorder;\n"
  },
  {
    "path": "libs/react-client/src/wavtools/wav_renderer.ts",
    "content": "const dataMap = new WeakMap();\n\n/**\n * Normalizes a Float32Array to Array(m): We use this to draw amplitudes on a graph\n * If we're rendering the same audio data, then we'll often be using\n * the same (data, m, downsamplePeaks) triplets so we give option to memoize\n */\nconst normalizeArray = (\n  data: Float32Array,\n  m: number,\n  downsamplePeaks: boolean = false,\n  memoize: boolean = false\n) => {\n  let cache, mKey, dKey;\n  if (memoize) {\n    mKey = m.toString();\n    dKey = downsamplePeaks.toString();\n    cache = dataMap.has(data) ? dataMap.get(data) : {};\n    dataMap.set(data, cache);\n    cache[mKey] = cache[mKey] || {};\n    if (cache[mKey][dKey]) {\n      return cache[mKey][dKey];\n    }\n  }\n  const n = data.length;\n  const result = new Array(m);\n  if (m <= n) {\n    // Downsampling\n    result.fill(0);\n    const count = new Array(m).fill(0);\n    for (let i = 0; i < n; i++) {\n      const index = Math.floor(i * (m / n));\n      if (downsamplePeaks) {\n        // take highest result in the set\n        result[index] = Math.max(result[index], Math.abs(data[i]));\n      } else {\n        result[index] += Math.abs(data[i]);\n      }\n      count[index]++;\n    }\n    if (!downsamplePeaks) {\n      for (let i = 0; i < result.length; i++) {\n        result[i] = result[i] / count[i];\n      }\n    }\n  } else {\n    for (let i = 0; i < m; i++) {\n      const index = (i * (n - 1)) / (m - 1);\n      const low = Math.floor(index);\n      const high = Math.ceil(index);\n      const t = index - low;\n      if (high >= n) {\n        result[i] = data[n - 1];\n      } else {\n        result[i] = data[low] * (1 - t) + data[high] * t;\n      }\n    }\n  }\n  if (memoize) {\n    cache[mKey as string][dKey as string] = result;\n  }\n  return result;\n};\n\nexport const WavRenderer = {\n  /**\n   * Renders a point-in-time snapshot of an audio sample, usually frequency values\n   * @param ctx\n   * @param data\n   * @param color\n   * @param cssWidth\n   * @param cssHeight\n   * @param pointCount number of bars to render\n   * @param barWidth width of bars in px\n   * @param barSpacing spacing between bars in px\n   * @param center vertically center the bars\n   */\n  drawBars: (\n    ctx: CanvasRenderingContext2D,\n    data: Float32Array,\n    cssWidth: number,\n    cssHeight: number,\n    color: string,\n    pointCount: number = 0,\n    barWidth: number = 0,\n    barSpacing: number = 0,\n    center: boolean = false\n  ) => {\n    pointCount = Math.floor(\n      Math.min(\n        pointCount,\n        (cssWidth - barSpacing) / (Math.max(barWidth, 1) + barSpacing)\n      )\n    );\n    if (!pointCount) {\n      pointCount = Math.floor(\n        (cssWidth - barSpacing) / (Math.max(barWidth, 1) + barSpacing)\n      );\n    }\n    if (!barWidth) {\n      barWidth = (cssWidth - barSpacing) / pointCount - barSpacing;\n    }\n    const points = normalizeArray(data, pointCount, true);\n    for (let i = 0; i < pointCount; i++) {\n      const amplitude = Math.abs(points[i]);\n      const height = Math.max(1, amplitude * cssHeight);\n      const x = barSpacing + i * (barWidth + barSpacing);\n      const y = center ? (cssHeight - height) / 2 : cssHeight - height;\n      const radius = Math.min(barWidth / 2, height / 2); // Calculate the radius for rounded corners\n\n      ctx.fillStyle = color;\n      ctx.beginPath();\n      ctx.moveTo(x + radius, y);\n      ctx.lineTo(x + barWidth - radius, y);\n      ctx.arcTo(x + barWidth, y, x + barWidth, y + radius, radius);\n      ctx.lineTo(x + barWidth, y + height - radius);\n      ctx.arcTo(\n        x + barWidth,\n        y + height,\n        x + barWidth - radius,\n        y + height,\n        radius\n      );\n      ctx.lineTo(x + radius, y + height);\n      ctx.arcTo(x, y + height, x, y + height - radius, radius);\n      ctx.lineTo(x, y + radius);\n      ctx.arcTo(x, y, x + radius, y, radius);\n      ctx.closePath();\n      ctx.fill();\n    }\n  }\n};\n"
  },
  {
    "path": "libs/react-client/src/wavtools/wav_stream_player.js",
    "content": "import { AudioAnalysis } from './analysis/audio_analysis.js';\nimport { StreamProcessorSrc } from './worklets/stream_processor.js';\n\n/**\n * Plays audio streams received in raw PCM16 chunks from the browser\n * @class\n */\nexport class WavStreamPlayer {\n  /**\n   * Creates a new WavStreamPlayer instance\n   * @param {{sampleRate?: number}} options\n   * @returns {WavStreamPlayer}\n   */\n  constructor({ sampleRate = 24000, onStop } = {}) {\n    this.scriptSrc = StreamProcessorSrc;\n    this.onStop = onStop;\n    this.sampleRate = sampleRate;\n    this.context = null;\n    this.stream = null;\n    this.analyser = null;\n    this.trackSampleOffsets = {};\n    this.interruptedTrackIds = {};\n  }\n\n  /**\n   * Connects the audio context and enables output to speakers\n   * @returns {Promise<true>}\n   */\n  async connect() {\n    this.context = new AudioContext({ sampleRate: this.sampleRate });\n    if (this.context.state === 'suspended') {\n      await this.context.resume();\n    }\n    try {\n      await this.context.audioWorklet.addModule(this.scriptSrc);\n    } catch (e) {\n      console.error(e);\n      throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`);\n    }\n    const analyser = this.context.createAnalyser();\n    analyser.fftSize = 8192;\n    analyser.smoothingTimeConstant = 0.1;\n    this.analyser = analyser;\n    return true;\n  }\n\n  /**\n   * Gets the current frequency domain data from the playing track\n   * @param {\"frequency\"|\"music\"|\"voice\"} [analysisType]\n   * @param {number} [minDecibels] default -100\n   * @param {number} [maxDecibels] default -30\n   * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType}\n   */\n  getFrequencies(\n    analysisType = 'frequency',\n    minDecibels = -100,\n    maxDecibels = -30\n  ) {\n    if (!this.analyser) {\n      throw new Error('Not connected, please call .connect() first');\n    }\n    return AudioAnalysis.getFrequencies(\n      this.analyser,\n      this.sampleRate,\n      null,\n      analysisType,\n      minDecibels,\n      maxDecibels\n    );\n  }\n\n  /**\n   * Starts audio streaming\n   * @private\n   * @returns {Promise<true>}\n   */\n  _start() {\n    const streamNode = new AudioWorkletNode(this.context, 'stream_processor');\n    streamNode.connect(this.context.destination);\n    streamNode.port.onmessage = (e) => {\n      const { event } = e.data;\n      if (event === 'stop') {\n        this.onStop?.();\n        streamNode.disconnect();\n        this.stream = null;\n      } else if (event === 'offset') {\n        const { requestId, trackId, offset } = e.data;\n        const currentTime = offset / this.sampleRate;\n        this.trackSampleOffsets[requestId] = { trackId, offset, currentTime };\n      }\n    };\n    this.analyser.disconnect();\n    streamNode.connect(this.analyser);\n    this.stream = streamNode;\n    return true;\n  }\n\n  /**\n   * Adds 16BitPCM data to the currently playing audio stream\n   * You can add chunks beyond the current play point and they will be queued for play\n   * @param {ArrayBuffer|Int16Array} arrayBuffer\n   * @param {string} [trackId]\n   * @returns {Int16Array}\n   */\n  add16BitPCM(arrayBuffer, trackId = 'default') {\n    if (typeof trackId !== 'string') {\n      throw new Error(`trackId must be a string`);\n    } else if (this.interruptedTrackIds[trackId]) {\n      return;\n    }\n    if (!this.stream) {\n      this._start();\n    }\n    let buffer;\n    if (arrayBuffer instanceof Int16Array) {\n      buffer = arrayBuffer;\n    } else if (arrayBuffer instanceof ArrayBuffer) {\n      buffer = new Int16Array(arrayBuffer);\n    } else {\n      throw new Error(`argument must be Int16Array or ArrayBuffer`);\n    }\n    this.stream.port.postMessage({ event: 'write', buffer, trackId });\n    return buffer;\n  }\n\n  /**\n   * Gets the offset (sample count) of the currently playing stream\n   * @param {boolean} [interrupt]\n   * @returns {{trackId: string|null, offset: number, currentTime: number}}\n   */\n  async getTrackSampleOffset(interrupt = false) {\n    if (!this.stream) {\n      return null;\n    }\n    const requestId = crypto.randomUUID();\n    this.stream.port.postMessage({\n      event: interrupt ? 'interrupt' : 'offset',\n      requestId\n    });\n    let trackSampleOffset;\n    while (!trackSampleOffset) {\n      trackSampleOffset = this.trackSampleOffsets[requestId];\n      await new Promise((r) => setTimeout(() => r(), 1));\n    }\n    const { trackId } = trackSampleOffset;\n    if (interrupt && trackId) {\n      this.interruptedTrackIds[trackId] = true;\n    }\n    return trackSampleOffset;\n  }\n\n  /**\n   * Strips the current stream and returns the sample offset of the audio\n   * @param {boolean} [interrupt]\n   * @returns {{trackId: string|null, offset: number, currentTime: number}}\n   */\n  async interrupt() {\n    return this.getTrackSampleOffset(true);\n  }\n}\n\nglobalThis.WavStreamPlayer = WavStreamPlayer;\n"
  },
  {
    "path": "libs/react-client/src/wavtools/worklets/audio_processor.js",
    "content": "const AudioProcessorWorklet = `\nclass AudioProcessor extends AudioWorkletProcessor {\n\n  constructor() {\n    super();\n    this.port.onmessage = this.receive.bind(this);\n    this.initialize();\n  }\n\n  initialize() {\n    this.foundAudio = false;\n    this.recording = false;\n    this.chunks = [];\n  }\n\n  /**\n   * Concatenates sampled chunks into channels\n   * Format is chunk[Left[], Right[]]\n   */\n  readChannelData(chunks, channel = -1, maxChannels = 9) {\n    let channelLimit;\n    if (channel !== -1) {\n      if (chunks[0] && chunks[0].length - 1 < channel) {\n        throw new Error(\n          \\`Channel \\${channel} out of range: max \\${chunks[0].length}\\`\n        );\n      }\n      channelLimit = channel + 1;\n    } else {\n      channel = 0;\n      channelLimit = Math.min(chunks[0] ? chunks[0].length : 1, maxChannels);\n    }\n    const channels = [];\n    for (let n = channel; n < channelLimit; n++) {\n      const length = chunks.reduce((sum, chunk) => {\n        return sum + chunk[n].length;\n      }, 0);\n      const buffers = chunks.map((chunk) => chunk[n]);\n      const result = new Float32Array(length);\n      let offset = 0;\n      for (let i = 0; i < buffers.length; i++) {\n        result.set(buffers[i], offset);\n        offset += buffers[i].length;\n      }\n      channels[n] = result;\n    }\n    return channels;\n  }\n\n  /**\n   * Combines parallel audio data into correct format,\n   * channels[Left[], Right[]] to float32Array[LRLRLRLR...]\n   */\n  formatAudioData(channels) {\n    if (channels.length === 1) {\n      // Simple case is only one channel\n      const float32Array = channels[0].slice();\n      const meanValues = channels[0].slice();\n      return { float32Array, meanValues };\n    } else {\n      const float32Array = new Float32Array(\n        channels[0].length * channels.length\n      );\n      const meanValues = new Float32Array(channels[0].length);\n      for (let i = 0; i < channels[0].length; i++) {\n        const offset = i * channels.length;\n        let meanValue = 0;\n        for (let n = 0; n < channels.length; n++) {\n          float32Array[offset + n] = channels[n][i];\n          meanValue += channels[n][i];\n        }\n        meanValues[i] = meanValue / channels.length;\n      }\n      return { float32Array, meanValues };\n    }\n  }\n\n  /**\n   * Converts 32-bit float data to 16-bit integers\n   */\n  floatTo16BitPCM(float32Array) {\n    const buffer = new ArrayBuffer(float32Array.length * 2);\n    const view = new DataView(buffer);\n    let offset = 0;\n    for (let i = 0; i < float32Array.length; i++, offset += 2) {\n      let s = Math.max(-1, Math.min(1, float32Array[i]));\n      view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);\n    }\n    return buffer;\n  }\n\n  /**\n   * Retrieves the most recent amplitude values from the audio stream\n   * @param {number} channel\n   */\n  getValues(channel = -1) {\n    const channels = this.readChannelData(this.chunks, channel);\n    const { meanValues } = this.formatAudioData(channels);\n    return { meanValues, channels };\n  }\n\n  /**\n   * Exports chunks as an audio/wav file\n   */\n  export() {\n    const channels = this.readChannelData(this.chunks);\n    const { float32Array, meanValues } = this.formatAudioData(channels);\n    const audioData = this.floatTo16BitPCM(float32Array);\n    return {\n      meanValues: meanValues,\n      audio: {\n        bitsPerSample: 16,\n        channels: channels,\n        data: audioData,\n      },\n    };\n  }\n\n  receive(e) {\n    const { event, id } = e.data;\n    let receiptData = {};\n    switch (event) {\n      case 'start':\n        this.recording = true;\n        break;\n      case 'stop':\n        this.recording = false;\n        break;\n      case 'clear':\n        this.initialize();\n        break;\n      case 'export':\n        receiptData = this.export();\n        break;\n      case 'read':\n        receiptData = this.getValues();\n        break;\n      default:\n        break;\n    }\n    // Always send back receipt\n    this.port.postMessage({ event: 'receipt', id, data: receiptData });\n  }\n\n  sendChunk(chunk) {\n    const channels = this.readChannelData([chunk]);\n    const { float32Array, meanValues } = this.formatAudioData(channels);\n    const rawAudioData = this.floatTo16BitPCM(float32Array);\n    const monoAudioData = this.floatTo16BitPCM(meanValues);\n    this.port.postMessage({\n      event: 'chunk',\n      data: {\n        mono: monoAudioData,\n        raw: rawAudioData,\n      },\n    });\n  }\n\n  process(inputList, outputList, parameters) {\n    // Copy input to output (e.g. speakers)\n    // Note that this creates choppy sounds with Mac products\n    const sourceLimit = Math.min(inputList.length, outputList.length);\n    for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {\n      const input = inputList[inputNum];\n      const output = outputList[inputNum];\n      const channelCount = Math.min(input.length, output.length);\n      for (let channelNum = 0; channelNum < channelCount; channelNum++) {\n        input[channelNum].forEach((sample, i) => {\n          output[channelNum][i] = sample;\n        });\n      }\n    }\n    const inputs = inputList[0];\n    // There's latency at the beginning of a stream before recording starts\n    // Make sure we actually receive audio data before we start storing chunks\n    let sliceIndex = 0;\n    if (!this.foundAudio) {\n      for (const channel of inputs) {\n        sliceIndex = 0; // reset for each channel\n        if (this.foundAudio) {\n          break;\n        }\n        if (channel) {\n          for (const value of channel) {\n            if (value !== 0) {\n              // find only one non-zero entry in any channel\n              this.foundAudio = true;\n              break;\n            } else {\n              sliceIndex++;\n            }\n          }\n        }\n      }\n    }\n    if (inputs && inputs[0] && this.foundAudio && this.recording) {\n      // We need to copy the TypedArray, because the \\`process\\`\n      // internals will reuse the same buffer to hold each input\n      const chunk = inputs.map((input) => input.slice(sliceIndex));\n      this.chunks.push(chunk);\n      this.sendChunk(chunk);\n    }\n    return true;\n  }\n}\n\nregisterProcessor('audio_processor', AudioProcessor);\n`;\n\nconst script = new Blob([AudioProcessorWorklet], {\n  type: 'application/javascript'\n});\nconst src = URL.createObjectURL(script);\nexport const AudioProcessorSrc = src;\n"
  },
  {
    "path": "libs/react-client/src/wavtools/worklets/stream_processor.js",
    "content": "export const StreamProcessorWorklet = `\nclass StreamProcessor extends AudioWorkletProcessor {\n  constructor() {\n    super();\n    this.hasStarted = false;\n    this.hasInterrupted = false;\n    this.outputBuffers = [];\n    this.bufferLength = 128;\n    this.write = { buffer: new Float32Array(this.bufferLength), trackId: null };\n    this.writeOffset = 0;\n    this.trackSampleOffsets = {};\n    this.port.onmessage = (event) => {\n      if (event.data) {\n        const payload = event.data;\n        if (payload.event === 'write') {\n          const int16Array = payload.buffer;\n          const float32Array = new Float32Array(int16Array.length);\n          for (let i = 0; i < int16Array.length; i++) {\n            float32Array[i] = int16Array[i] / 0x8000; // Convert Int16 to Float32\n          }\n          this.writeData(float32Array, payload.trackId);\n        } else if (\n          payload.event === 'offset' ||\n          payload.event === 'interrupt'\n        ) {\n          const requestId = payload.requestId;\n          const trackId = this.write.trackId;\n          const offset = this.trackSampleOffsets[trackId] || 0;\n          this.port.postMessage({\n            event: 'offset',\n            requestId,\n            trackId,\n            offset,\n          });\n          if (payload.event === 'interrupt') {\n            this.hasInterrupted = true;\n          }\n        } else {\n          throw new Error(\\`Unhandled event \"\\${payload.event}\"\\`);\n        }\n      }\n    };\n  }\n\n  writeData(float32Array, trackId = null) {\n    let { buffer } = this.write;\n    let offset = this.writeOffset;\n    for (let i = 0; i < float32Array.length; i++) {\n      buffer[offset++] = float32Array[i];\n      if (offset >= buffer.length) {\n        this.outputBuffers.push(this.write);\n        this.write = { buffer: new Float32Array(this.bufferLength), trackId };\n        buffer = this.write.buffer;\n        offset = 0;\n      }\n    }\n    this.writeOffset = offset;\n    return true;\n  }\n\n  process(inputs, outputs, parameters) {\n    const output = outputs[0];\n    const outputChannelData = output[0];\n    const outputBuffers = this.outputBuffers;\n    if (this.hasInterrupted) {\n      this.port.postMessage({ event: 'stop' });\n      return false;\n    } else if (outputBuffers.length) {\n      this.hasStarted = true;\n      const { buffer, trackId } = outputBuffers.shift();\n      for (let i = 0; i < outputChannelData.length; i++) {\n        outputChannelData[i] = buffer[i] || 0;\n      }\n      if (trackId) {\n        this.trackSampleOffsets[trackId] =\n          this.trackSampleOffsets[trackId] || 0;\n        this.trackSampleOffsets[trackId] += buffer.length;\n      }\n      return true;\n    } else if (this.hasStarted) {\n      this.port.postMessage({ event: 'stop' });\n      return false;\n    } else {\n      return true;\n    }\n  }\n}\n\nregisterProcessor('stream_processor', StreamProcessor);\n`;\n\nconst script = new Blob([StreamProcessorWorklet], {\n  type: 'application/javascript'\n});\nconst src = URL.createObjectURL(script);\nexport const StreamProcessorSrc = src;\n"
  },
  {
    "path": "libs/react-client/tsconfig.build.json",
    "content": "{\n  // This is for tsup build since it's not supporting composite\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": false\n  }\n}\n"
  },
  {
    "path": "libs/react-client/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    // THIS MUST BE AT ROOT, if you set baseurl in sub-package it breaks intellisense jump to\n    \"composite\": true,\n    \"baseUrl\": \".\",\n    \"rootDir\": \".\",\n    \"outDir\": \"dist\",\n    \"importHelpers\": true,\n    \"allowJs\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"declaration\": true,\n    \"downlevelIteration\": true,\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"jsx\": \"react-jsx\",\n    \"module\": \"system\",\n    \"moduleResolution\": \"node\",\n    \"noEmitOnError\": false,\n    \"noImplicitAny\": false,\n    \"noImplicitReturns\": false,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"preserveConstEnums\": true,\n    \"removeComments\": true,\n    \"skipLibCheck\": true,\n    \"sourceMap\": true,\n    \"strictNullChecks\": true,\n    \"target\": \"es5\",\n    \"types\": [\"node\", \"react\"],\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"paths\": {\n      \"src/*\": [\"./src/*\"]\n    }\n  },\n  \"exclude\": [\"**/test\", \"**/dist\", \"**/__tests__\"],\n  \"include\": [\"src/**/*\"],\n  \"types\": [\"@testing-library/jest-dom\", \"node\"]\n}\n"
  },
  {
    "path": "lint-staged.config.js",
    "content": "// eslint-disable-next-line no-undef\nmodule.exports = {\n  '**/*.{js,jsx,ts,tsx}': ['npx prettier --write', 'npx eslint --fix'],\n  '**/*.{ts,tsx}': [() => 'tsc --skipLibCheck --noEmit'],\n  '**/*.py': [\n    'uv run --project backend ruff check --fix',\n    'uv run --project backend ruff format',\n    () => 'pnpm run lintPython'\n  ],\n  '.github/workflows/**': ['actionlint']\n};\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"devDependencies\": {\n    \"@trivago/prettier-plugin-sort-imports\": \"^4.3.0\",\n    \"@types/react\": \"^18.2.15\",\n    \"@types/react-dom\": \"^18.2.7\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.15.0\",\n    \"@typescript-eslint/parser\": \"^8.15.0\",\n    \"cypress\": \"^14.5.3\",\n    \"cypress-plugin-steps\": \"1.2.1\",\n    \"dotenv\": \"^16.3.1\",\n    \"eslint\": \"^8.57.1\",\n    \"husky\": \"^9.1.6\",\n    \"fkill\": \"^9.0.0\",\n    \"lint-staged\": \"^13.3.0\",\n    \"prettier\": \"^2.8.8\",\n    \"shell-exec\": \"^1.1.2\",\n    \"ts-node\": \"^10.9.1\",\n    \"typescript\": \"^5.2.2\"\n  },\n  \"scripts\": {\n    \"preinstall\": \"npx only-allow pnpm\",\n    \"test\": \"cypress run\",\n    \"test:interactive\": \"cypress open\",\n    \"test:ui\": \"cd frontend && pnpm test\",\n    \"prepare\": \"husky\",\n    \"lint\": \"pnpm run lintUi && pnpm run lintPython\",\n    \"lintUi\": \"pnpm run --parallel lint\",\n    \"formatUi\": \"pnpm run --parallel format\",\n    \"lintPython\": \"cd backend && uv run dmypy run -- chainlit/ tests/\",\n    \"formatPython\": \"black `git ls-files | grep '.py$'` && isort --profile=black .\",\n    \"build:libs\": \"cd libs/react-client && pnpm run build && cd ../copilot && pnpm run build\",\n    \"buildUi\": \"pnpm build:libs && cd frontend && pnpm run build\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"braces@<3.0.3\": \">=3.0.3\",\n      \"micromatch@<4.0.8\": \">=4.0.8\"\n    }\n  },\n  \"packageManager\": \"pnpm@9.15.9\"\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - frontend/\n  - libs/react-client/\n  - libs/copilot/\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"ESNext\", \"dom\"],\n    \"types\": [\"cypress\", \"cypress-plugin-steps\", \"node\"],\n    \"baseUrl\": \".\",\n  },\n  \"include\": [\"cypress/**/*.ts\"]\n}\n"
  }
]